Main Chain B · Step 12

assemble:片段如何变成最终提示词

08 页 preStep 的第二步调了 systemPrompt.assemble()。这一页看 packages/core/system-prompt/src/index.ts(638 行)的 SystemPrompt 服务:各插件贡献的有序片段、动态上下文、工具 schema 如何合并成一份 PromptAssembly。

08 agent.ts:271 preStep systemPrompt.assemble(assembleContextFor(...)) → system-prompt/src/index.ts:558 assemble → (下一步) 11 agent.ts:387 renderPrompt(assembly)

示例本次示例:step 1 的组装实况

示例轨迹 12-1 · assemble 的合并输入输出
# 08 页 230 行调用 systemPrompt.assemble(...)
# 各插件已注册的片段(假设):
#   global:  section 'harness:identity'  order:-100  text:"You are dsh..."
#            section 'deployment:persona' order:0     text:"{{persona}}"
#   tool 指引 section 'tool-guidance'   order:100
#   variables: { persona: provider→'一个严谨的工程师助手' }
#   toolProviders: [tools 包的 provider → 可见工具 schema 白名单投影]
# 合并(本页 564-581 行):
#   variables = { persona: '一个严谨的工程师助手' }
#   sections 按 order 排序:identity(-1000) → persona(0) → tool-guidance(1000+)
#   collected = [{name:'bash',description,parameters}, {name:'read',...}, ...]
# → PromptAssembly 过 system-prompt/assemble waterfall(无改写 → 原样)
# → renderPrompt(system-prompt/src/index.ts:279):{{persona}} → '一个严谨的工程师助手'
# → system 文本进 09 页 canonicalHeader → request/header.system
来源:12 页行级解读 + fixture 的 request/header 行里 system:"SYS PROMPT" 的位置(06 页示例 06-1)
示例轨迹 12-2 · 为什么模型看到的 tools 只有三字段
# 14 页 registry 里的 ToolDefinition 有 execute/timeoutMs/isConcurrencySafe...
# 但 12 页 586-591 行只投影 {name, description, parameters}(外加可选的 deferLoading):
# { "name":"bash", "description":"Run a command...", "parameters":{...} }
# execute 等实现细节永不下发——模型只需要知道「能叫什么、怎么传参」
# 这正是 06 页 fixture 里 tools 数组的字段(只有 t1/D1/parameters)
来源:12 页 586-591 行 + 14 页 schemaOf 白名单

§1合并语义:variables / sections / tools

packages/core/system-prompt/src/index.tsassemble 主体(节选)558-599
558  async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
559    const scope = context.scope
560    const scopeLayers = this.layers.chainLayers(scope)
561    const runtimeContextSuppressed = !this.layers.global.runtimeContextSuppressors.isEmpty()
562      || scopeLayers.some(layer => !layer.runtimeContextSuppressors.isEmpty())
564    const variables: Record<string, string | undefined> = {}
565    for (const [name, provider] of this.layers.global.variables.entries()) { ... }
569    for (const layer of scopeLayers) {           // Farthest first → nearest scope wins
571        variables[name] = provider(context)
572    }
575    const sectionByName = this.layers.merge(scope, layer => layer.sections)
576    const contextByName = this.layers.merge(scope, layer => layer.contexts)
578    const providers = [                   // tools: global + ALL matched scopes 都贡献
579      ...this.layers.global.toolProviders.values(),
580      ...scopeLayers.flatMap(layer => [...layer.toolProviders.values()]),
581    ]
582    const collected: ToolSchema[] = []
583    const knownNames = new Set<string>()
584    for (const provider of providers) {
585      const result = provider(context)
586      const schemas = result.schemas.map(({ name, description, parameters, deferLoading }): ToolSchema => ({0.1.7-rc.1:多透传 deferLoading
587        name,
588        description,
589        parameters: structuredClone(parameters),   // 只投影白名单字段
590        ...deferLoading === true ? { deferLoading } : {},
591      }))
593      collected.push(...schemas)
596    const sectionDefinitions = [...sectionByName.values()].sort(comparePromptSections)
597    const completeSections = sectionDefinitions.filter(section => section.complete === true)
598    if (completeSections.length > 1) {
599      throw new Error(`multiple complete prompt sections are active: ...`)
559-560

scopeLayers:该 scope 的层链。合并基于 ScopedLayers(scope 库)——global 层 + 从远到近的 scope 层。

561-562

runtimeContextSuppressors:任何层有 suppressor 就整体抑制运行时上下文(08 页 project 的开关)。

564-571

variables:同名时 scoped 覆盖 global——先填 global,再从远到近遍历 scope 层,最近的作用域最后写、赢下同名。Provider 函数在调用时求值。

575-576

sections/contexts 走 layers.merge(scope, ...)——同样的覆盖语义。

578-581

tools 不同:global + 全部匹配 scope 都 append(贡献而非覆盖)——与 variables 的语义相反。一个 agent 作用域的工具不会遮蔽全局工具,只会增加。

586-591

工具 schema 进模型的唯一路径:只投影 name/description/parameters 三个字段(structuredClone 防消费者改原对象)。timeoutMs、isConcurrencySafe、execute 等元数据永不下发。0.1.7-rc.1 新增 deferLoading 透传(590 行):provider 想让某个工具的定义延迟加载时,这个标记会跟着 schema 一起进模型请求——用的是 Anthropic 的 defer_loading 术语,只在不支持的能力适配器里才报不支持。

596

order 带决定顺序:-1000 = harness:identity(SECTION_ORDERS.HARNESS_IDENTITY),0 = persona 前缀,工具指引从 1000(TOOL_BASH)往上一路排到 5000/9900/10100 等;同 order 靠注册顺序破平(插件加载顺序的产物)。工具另有 toolOrder 排序。0.1.7-rc.1 起 SECTION_ORDERS 里的 TOOL_CORDIS 条目已删除。

597-599

complete 节:fail-loud——多个 effective complete 直接 throw。complete:true 的节在 waterfall 之后恢复为唯一节(§2)。

§2waterfall:system-prompt/assemble

组装完基础结构后,整个 assembly 过一道 system-prompt/assemble waterfall(scope-filtered;声明在 index.ts:31,派发在 625-626 行):

  • 返回值为权威 PromptAssembly——监听器可改写/替换 sections 与 tools,可增删上下文。
  • complete 节的恢复发生在 waterfall 之后——所以监听器看到的可能不是最终形态,但恢复是系统保证。
  • 伴随的 system-prompt/change 是 emit 通知(任何 section/context/variable/tools/suppressor 注册或注销时发,不做 scope 过滤——全局变化影响所有 scope),消费者据此失效缓存。
  • invariant.ts(60 行)校验 waterfall 返回的权威 assembly 结构。

两个事件模式分清:system-prompt/assemble 是 waterfall(可拦截/短路,返回值权威);system-prompt/change 是 emit(payload-free 通知,非否决)。同一模式在 10 页 llm 出现过:stream=waterfall,adapters-updated=emit。

§3renderPrompt:严格插值

11 页 agent.ts:387 调 renderPrompt(assembly)(system-prompt/src/index.ts:279)产出最终 system 文本。四条规则(fail-loud 取向):

  1. 未注册的变量(Object.hasOwn 防原型名攻击)→ throw
  2. 注册但值为 undefined → throw
  3. 畸形的完整组({{}} 里不是合法名)→ throw
  4. 孤立 {{ 后无 }} → 字面量(不是变量,保留原文)

渲染出的文本不再进入 request/header。0.1.5 起它交给 SystemPromptProjection.project()(core/agent-loop/src/runtime-context.ts:88),由它决定以 append 还是 replace 的方式落成 system/message surface 节点(agent.ts:395-404)。模型看到的 prompt 依然可从日志重建——只是承载它的事件换了地方,而且现在能承载「多个版本」而非单个字符串。

§40.1.5:从 header 字段到 surface 节点

这一版把 system prompt 从「请求头里的一个字段」提升为「日志里的一个 surface 节点」。三处代码是这个转变的落点:

层0.1.3-alpha.20.1.5
事件request/header.system: stringsystem/message { turn, step, message }(core/session/src/types.ts:330)
surface非 surface 事件SurfaceEventType 的新成员——0.1.5 时排第 4,如今列在联合首位(core/session/src/types.ts:439-444,0.1.7 起共 5 个成员)——surface 节点 0
格式版本SESSION_FORMAT_VERSION = 20.1.5 时升到 3;现在(0.1.7)是 4(core/session/src/types.ts:89)
校验无validateSessionEventData 拒绝带 system 的 header(core/session/src/surface.ts:172)
能力位无systemPromptUpdate: 'in-history'(llm/src/index.ts:784)

为什么值得换?旧结构下 system prompt 是请求的一个属性——它随请求生灭,日志里只有「最后一次用的是什么」。新结构下它是一个surface 节点,于是:

  • 替换而非删除:prompt 变化时用 surfaceOp: {op:'replace'} 遮蔽旧节点(core/agent-loop/src/runtime-context.ts:108),历史版本留在日志里。
  • 能在历史中间追加:适配器声明 in-history 能力时,prompt 的变化作为一条新的 system/message 追加在对话流中——模型能感知到「系统指令刚刚变了」。这是 project()(runtime-context.ts:88)里 102 行的 surfaceOp: 'append' 分支。
  • 与其他 surface 节点同构:compaction 的 replace、工具结果的 append 与它走同一套 planSurfaceEvent 机制——surface 只有一种规则。

格式版本升级曾是硬断点:SESSION_FORMAT_VERSION 从 2 升到 3 时,旧会话日志不能在新版本里恢复——那是「宁可起不来,也不静默读错」的又一次体现。0.1.7 起这条策略松了一档:格式目录带上了迁移链(session-format-v0-to-v1 → … → v3-to-v4,现在当前版本是 4),读旧日志时会先按链升级到当前格式、再走与 append 相同的校验(20 页 §3);只有「比本 build 更新」的日志仍然被直接拒绝。