close

Main Chain B · Step 09

buildRequest:从日志折叠请求

agent.ts:426-514这是「模型可见 ⟺ 已记录」最集中的实现:请求的每个部分——config、system、tools、messages——全部从日志折叠而来,然后冻结成一个不可变对象。你还将看到 prepareCall 的一次性派发守卫。

11 step:340 buildRequest(turn, step, tools, system, deriveMessages(), signal) agent.ts:426 buildRequest (分叉) llm.prepareCall → 10 页 · session.requestHeader → 06 页 (下一步) 10 llm/stream

示例本次示例:step 1 请求的折叠实况

示例轨迹 09-1 · 首次请求的 buildRequest 输入输出
# 调用(11 页 340 行):
# buildRequest(1, 1, assembly.tools, system, session.deriveMessages(), signal)
# 其中 boundaryMessages = deriveMessages() 的当前投影:
#   [ user/message「帮我列一下当前目录」(seq=2) ]     ← 从日志投影,不是内存对象

# ① 折叠上次配置(438 行):日志里还没有 request/header → persistedHeader=undefined
#    → seedConfig = { provider:'deepseek-official', model:'deepseek-v4-pro',
#                     reasoningEffort:'max' }        ← 来自 AgentOptions
# ② agent/request waterfall(457 行):无监听器改写 → proposedConfig = seedConfig
# ③ prepareCall(468 行):绑定 adapter 注册 + 物化模型默认值
#    → config = { ...proposedConfig, maxTokens: 模型目录的默认 32768 }
#      (真实默认:llm-deepseek 的 DEFAULT_MAX_TOKENS)
# ④ header 落盘(484-488 行):reason:'initial'
#    { "type":"request/header", "data":{ "header":{
#        "config":{...}, "adapterDefaults":{maxTokens:true}, "system":"...", "tools":[...] },
#      "reason":"initial" } }
# ⑤ 最终请求(505 行):markAgentLoopRequest(deepFreeze({...}))
来源:09 页行级解读 + 真实 fixture 的 request/header 行格式(06 页示例轨迹 06-1)
示例轨迹 09-2 · 第二个请求为什么不再记 header
# step 2 的 buildRequest:baseline = 上次的 header(484 行 requestHeader())
# config/system/tools 都没变 → headerEquals(baseline, header) === true
# → 不落新 request/header 事件(488 行的 else-if 不命中)
# → 日志里 header 历史只有一条 initial——「配置如何演变」的记录是稀疏的
来源:09 页 483-489 行 + 06 页 requestHeader 的增量缓存

§1完整代码与行级解读

packages/core/agent-loop/src/agent.tsbuildRequest 全文426-514
426  private async buildRequest(turn, step, tools, system, boundaryMessages, signal) {
438    const persistedHeader = session.requestHeader()
439    const persistedConfig = persistedHeader?.config
440    const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
441    const reasoningEffort = persistedConfig?.provider === route.provider
442      && persistedConfig.model === route.model
443      && persistedHeader?.adapterDefaults?.reasoningEffort !== true
444      ? persistedConfig.reasoningEffort : undefined
446    const maxTokens = this.options.maxTokens
447    const seedConfig = deepFreeze(structuredClone(
448      this.requestHeaderLogged
450        ? requestProposal(persistedHeader!)
451        : {
452          ...route,
453          ...reasoningEffort === undefined ? {} : { reasoningEffort },
454          ...maxTokens === undefined ? {} : { maxTokens },
455        },
456    ))
457    const proposedConfig = await this.dispatch.waterfall(
458      'agent/request', { turn, step, signal },
459      () => Promise.resolve(seedConfig),
460    )
461    signal.throwIfAborted()
462    if (!proposedConfig.provider || !proposedConfig.model) {
463      throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
464    }
465    let config: LlmCallConfig
466    let preparedCall: PreparedLlmCall | undefined
467    try {
468      preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
469      config = preparedCall.config
470    } catch (error) {
472      if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
473      config = proposedConfig
474    }
475    signal.throwIfAborted()
477    const header = canonicalHeader({
478      config,
479      ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
480      ...system ? { system } : {},
481      ...tools.length > 0 ? { tools } : {},
482    })
483    const baseline = this.session.requestHeader()
484    if (!this.requestHeaderLogged) {
485      this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
486      this.requestHeaderLogged = true
487    } else if (baseline === undefined || !headerEquals(baseline, header)) {
488      this.session.append('request/header', { header, reason: 'change' })
489    }
491    const contextWindow = preparedCall?.context?.contextWindow
492    const requestContext: RequestContext = { provider: config.provider, model: config.model,
495      ...contextWindow === undefined ? {} : { contextWindow } }
497    const previousContext = session.requestContext()
498    if (previousContext?.provider !== requestContext.provider
499      || previousContext.model !== requestContext.model
500      || previousContext.contextWindow !== requestContext.contextWindow) {
501      session.append('request/context', requestContext)
502    }
503    signal.throwIfAborted()
505    const request = markAgentLoopRequest(deepFreeze({
506      ...header.config,
507      messages: boundaryMessages,
508      ...header.system !== undefined ? { system: header.system } : {},
509      ...header.tools !== undefined ? { tools: header.tools } : {},
510      sessionId: this.session.id,
511      signal,
512    }))
513    return { request, ...preparedCall === undefined ? {} : { preparedCall } }
514  }
438-444

① 从日志折叠上次配置requestHeader()(06 页的增量缓存 fold)拿上次请求的完整 header。reasoningEffort 的恢复有严格条件:provider model 都与路由完全匹配、该 effort 不是 adapter 默认值——防止跨模型残留一个无意义的 effort。

447-456

seedConfig 两态:首个请求(requestHeaderLogged=false)从 AgentOptions 出发;后续请求从持久 header 折叠(requestProposal,§2)。整体 structuredClone + deepFreeze——waterfall 拿到的是冻结的种子,监听器只能构建替换值。

457-460

agent/request waterfall(第二个扩展点):插件可补 provider/model 或整体替换配置。JSDoc 硬约束:不能改 messages——模型可见内容必须走日志通道(messages 是 deriveMessages() 的产物,参数里根本没有它)。

462-464

fail-loud:waterfall 之后 provider/model 必须齐备,报错信息直接给出两种修复路径(设 AgentOptions 或经 agent/request 提供)。

467-474

③ prepareCall(§3)。NO_ADAPTER 被特别宽容:中间件可能服务未注册的 route,终局派发(stream)仍会要求 adapter——所以这里回退到 proposedConfig,把失败推迟到 10 页的 stream。

477-482

canonicalHeader:把 config、adapterDefaults、system、tools 折叠成一个规范 header——空 optional 字段缺席(system 为空则不出现,tools 为空则缺席)。这就是 request/header 事件的数据。

483-489

④ header 落盘:本实例第一次请求记 initial/resume(日志里已有 header 事件就是 resume——进程重启/fork seed);之后只在 headerEquals 判定真实变化时记 change。header 历史是「请求配置如何演变」的持久记录。

491-502

request/context 记录 provider/model/contextWindow——只在路由或容量变化时落盘(不参与请求重建或 header 相等性)。它是遥测/UI 的「容量元数据」。

505-512

⑤ 最终请求messages: boundaryMessages——这个参数来自 11 页 step 的 session.deriveMessages()不是任何内存对象。deepFreeze + markAgentLoopRequest 打标:invariant.ts 就靠这个标记断言「请求 frozen、带 sessionId、messages 与 deriveMessages() 一致」。请求对象由此不可变。

513

返回 request + 可选的 preparedCall。11 页的 step 优先用 preparedCall.stream(),没有才退回 ctx.llm.stream()

为什么请求必须从日志折叠?看 507 行——messages 是 deriveMessages() 对日志的投影。任何想给模型塞内容的路径都只能先 append 到日志。「模型可见 ⟺ 已记录」在这里从纪律变成结构上不可能违反的约束:请求本身就是日志的函数。

§2requestProposal:adapter 默认值的剥离

packages/core/agent-loop/src/agent.ts剥离 adapter 派生值54-61
54/** Remove adapter-derived values before plugins propose the next request config. */
55function requestProposal(header: EpochHeader): LlmCallConfig {
56  if (header.adapterDefaults === undefined) return header.config
57  const proposal = { ...header.config }
58  if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort
59  if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens
60  return proposal
61}
54-61

上一次请求的 config 里可能混着 adapter 物化出来的默认值(调用方没指定,adapter 按模型目录补上的)。如果把它们原样作为下一次提案的种子,切换模型后这些值会「粘」在新模型上。所以:凡是 adapterDefaults 标记过的字段,从提案里删掉,让下一个 adapter 重新物化自己的默认值。这就是 441-444 行「effort 只在同模型时恢复」的姊妹逻辑。

§3prepareCall:一次性派发 + 代际绑定

prepareCallpackages/llm/llm/src/index.ts:824(10 页会看到 LlmRuntime 全貌)。这里只看它守卫的部分(llm/src/index.ts:841-861):

packages/llm/llm/src/index.tsstream 闭包里的守卫841-861
841    let dispatched = false
842    return Object.freeze({
843      config: resolvedConfig,
844      retryPolicy: registration.retryPolicy,
845      adapterDefaults,
846      ...context === undefined ? {} : { context },
847      ...modelInfo.inputModalities === undefined
848        ? {}
849        : { inputModalities: Object.freeze([...modelInfo.inputModalities]) },
850      stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
851        if (dispatched) {
852          throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
853        }
854        if (!callConfigEquals(options, resolvedConfig)) {
855          throw new LlmError(
856            'prepared LLM call config changed before adapter dispatch',
857            'INVALID_PREPARED_CALL',
858          )
859        }
860        dispatched = true
861        return this.streamWithRegistration(options, { registration, ... })
841-860

两个守卫:① dispatched——一个 prepared call 只能派发一次;② callConfigEquals——派发时的请求 config 必须与 prepare 时解析出的 config 完全一致。违者 INVALID_PREPARED_CALL

842-849

返回对象整体 freeze;resolvedConfig 在 829 行已 deepFreeze + structuredClone。0.1.1-rc.2 起还携带 inputModalities(该 adapter 代际捕获的精确模型模态,同样冻结)——prepare 之后的一切都是不可变的。

824-828

0.1.1-rc.2 的新机制:代际绑定。prepareCall 现在先调 registration.adapter.prepareCall(provider, model, signal)LlmAdapter.prepareCall,llm/src/index.ts:247,动态 adapter 可 override)拿到与「最终 stream 调用」同一代际的精确模型元数据(PreparedAdapterCall),再 resolve 配置——设置变更发生在 prepare 与 dispatch 之间时,不可能把一代的能力与另一代的 endpoint 拼起来。

为什么需要这个机制?配置树可以热重组(04 页)。一次 prepare 把能力结果(contextWindow、adapterDefaults、retryPolicy、inputModalities)绑定到那个时刻的 adapter 注册与模型代际。如果不守卫,HMR 后可能把 A adapter 的能力结果和 B adapter 的请求拼在一起。prepared call 要么原样用掉,要么明确失败,绝不静默换 adapter。