Main Chain B · Step 12
assemble:片段如何变成最终提示词
08 页 preStep 的第二步调了 systemPrompt.assemble()。这一页看 packages/core/system-prompt/src/index.ts(638 行)的 SystemPrompt 服务:各插件贡献的有序片段、动态上下文、工具 schema 如何合并成一份 PromptAssembly。
示例本次示例:step 1 的组装实况
# 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
# 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)
§1合并语义:variables / sections / tools
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: ...`)
scopeLayers:该 scope 的层链。合并基于 ScopedLayers(scope 库)——global 层 + 从远到近的 scope 层。
runtimeContextSuppressors:任何层有 suppressor 就整体抑制运行时上下文(08 页 project 的开关)。
variables:同名时 scoped 覆盖 global——先填 global,再从远到近遍历 scope 层,最近的作用域最后写、赢下同名。Provider 函数在调用时求值。
sections/contexts 走 layers.merge(scope, ...)——同样的覆盖语义。
tools 不同:global + 全部匹配 scope 都 append(贡献而非覆盖)——与 variables 的语义相反。一个 agent 作用域的工具不会遮蔽全局工具,只会增加。
工具 schema 进模型的唯一路径:只投影 name/description/parameters 三个字段(structuredClone 防消费者改原对象)。timeoutMs、isConcurrencySafe、execute 等元数据永不下发。0.1.7-rc.1 新增 deferLoading 透传(590 行):provider 想让某个工具的定义延迟加载时,这个标记会跟着 schema 一起进模型请求——用的是 Anthropic 的 defer_loading 术语,只在不支持的能力适配器里才报不支持。
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 条目已删除。
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 取向):
- 未注册的变量(
Object.hasOwn防原型名攻击)→ throw - 注册但值为
undefined→ throw - 畸形的完整组(
{{}}里不是合法名)→ throw - 孤立
{{后无}}→ 字面量(不是变量,保留原文)
渲染出的文本不再进入 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.2 | 0.1.5 |
|---|---|---|
| 事件 | request/header.system: string | system/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 = 2 | 0.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 更新」的日志仍然被直接拒绝。