Main Chain B · Step 09
buildRequest:从日志折叠请求
agent.ts:426-514。这是「模型可见 ⟺ 已记录」最集中的实现:请求的每个部分——config、system、tools、messages——全部从日志折叠而来,然后冻结成一个不可变对象。你还将看到 prepareCall 的一次性派发守卫。
示例本次示例:step 1 请求的折叠实况
# 调用(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({...}))
# step 2 的 buildRequest:baseline = 上次的 header(484 行 requestHeader()) # config/system/tools 都没变 → headerEquals(baseline, header) === true # → 不落新 request/header 事件(488 行的 else-if 不命中) # → 日志里 header 历史只有一条 initial——「配置如何演变」的记录是稀疏的
§1完整代码与行级解读
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 }
① 从日志折叠上次配置:requestHeader()(06 页的增量缓存 fold)拿上次请求的完整 header。reasoningEffort 的恢复有严格条件:provider 且 model 都与路由完全匹配、且该 effort 不是 adapter 默认值——防止跨模型残留一个无意义的 effort。
seedConfig 两态:首个请求(requestHeaderLogged=false)从 AgentOptions 出发;后续请求从持久 header 折叠(requestProposal,§2)。整体 structuredClone + deepFreeze——waterfall 拿到的是冻结的种子,监听器只能构建替换值。
② agent/request waterfall(第二个扩展点):插件可补 provider/model 或整体替换配置。JSDoc 硬约束:不能改 messages——模型可见内容必须走日志通道(messages 是 deriveMessages() 的产物,参数里根本没有它)。
fail-loud:waterfall 之后 provider/model 必须齐备,报错信息直接给出两种修复路径(设 AgentOptions 或经 agent/request 提供)。
③ prepareCall(§3)。NO_ADAPTER 被特别宽容:中间件可能服务未注册的 route,终局派发(stream)仍会要求 adapter——所以这里回退到 proposedConfig,把失败推迟到 10 页的 stream。
canonicalHeader:把 config、adapterDefaults、system、tools 折叠成一个规范 header——空 optional 字段缺席(system 为空则不出现,tools 为空则缺席)。这就是 request/header 事件的数据。
④ header 落盘:本实例第一次请求记 initial/resume(日志里已有 header 事件就是 resume——进程重启/fork seed);之后只在 headerEquals 判定真实变化时记 change。header 历史是「请求配置如何演变」的持久记录。
request/context 记录 provider/model/contextWindow——只在路由或容量变化时落盘(不参与请求重建或 header 相等性)。它是遥测/UI 的「容量元数据」。
⑤ 最终请求:messages: boundaryMessages——这个参数来自 11 页 step 的 session.deriveMessages(),不是任何内存对象。deepFreeze + markAgentLoopRequest 打标:invariant.ts 就靠这个标记断言「请求 frozen、带 sessionId、messages 与 deriveMessages() 一致」。请求对象由此不可变。
返回 request + 可选的 preparedCall。11 页的 step 优先用 preparedCall.stream(),没有才退回 ctx.llm.stream()。
为什么请求必须从日志折叠?看 507 行——messages 是 deriveMessages() 对日志的投影。任何想给模型塞内容的路径都只能先 append 到日志。「模型可见 ⟺ 已记录」在这里从纪律变成结构上不可能违反的约束:请求本身就是日志的函数。
§2requestProposal:adapter 默认值的剥离
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}
上一次请求的 config 里可能混着 adapter 物化出来的默认值(调用方没指定,adapter 按模型目录补上的)。如果把它们原样作为下一次提案的种子,切换模型后这些值会「粘」在新模型上。所以:凡是 adapterDefaults 标记过的字段,从提案里删掉,让下一个 adapter 重新物化自己的默认值。这就是 441-444 行「effort 只在同模型时恢复」的姊妹逻辑。
§3prepareCall:一次性派发 + 代际绑定
prepareCall 在 packages/llm/llm/src/index.ts:824(10 页会看到 LlmRuntime 全貌)。这里只看它守卫的部分(llm/src/index.ts: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, ... })
两个守卫:① dispatched——一个 prepared call 只能派发一次;② callConfigEquals——派发时的请求 config 必须与 prepare 时解析出的 config 完全一致。违者 INVALID_PREPARED_CALL。
返回对象整体 freeze;resolvedConfig 在 829 行已 deepFreeze + structuredClone。0.1.1-rc.2 起还携带 inputModalities(该 adapter 代际捕获的精确模型模态,同样冻结)——prepare 之后的一切都是不可变的。
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。