06 · Projection 与 Prompt:从日志得到不可变的下一次请求¶
这一章把三个常被合称“上下文”的东西分开:模型消息 surface、领域状态 projection、以及本步骤尚未提交的 PromptAssembly。掌握这一区分以后,才能写出可恢复、可审计的自己的 Agent。
6.1 surface 并非原始 event 数组¶
surface 是一列能产生消息的 event seq:system/developer/user/assistant/tool-result。turn、request/header、attempt 等 log-only 记录不在列中。append 把新 seq 放到尾部;replace 将已有 surface 两个 endpoint 所夹的节点换成新的 seq。endpoint 是当前 surface 中的身份,不是直接数组下标,也不要求中间原始 seq 连续。变换计划。
例:日志里 seq 2、4、7 是消息,3、5、6 是边界或元数据。新的 seq 8 声明 replace(2,4),则 surface 从 [2,4,7] 变为 [8,7],日志仍有 0…8。sourceEventSeqs 必须包含每个实际被覆盖的旧 surface node,并且不能重复、不能为空或引用未来。tool/result replace 只能改一个 current tool/result 的 content,其他 data 和 message identity 要保持;node 0 若为 system/message,仅允许 system/message 精确覆盖这个头。替换限制。
人类 transcript 需要原始 append-origin 事件,不能用 current surface,否则 compaction 会抹掉已展示对话。模型上下文与人类历史共用事件事实,却需要不同投影。
6.2 SurfaceManager 先验证计划,再提交¶
validateNext 先折叠已有 delta,计算 expected seq,并将 candidate 与 plan 暂存;它不先改当前 nodes。log.push 成功后 _processDelta 发现恰好是该候选对象和 seq,复用验证过的 plan。重放或不同 candidate 走同一个 planSurfaceEvent 算法,所以 live append 与 offline fold 不应有两套替换语义。增量管理器。
replace 增 replaceGeneration 和 contentGeneration;pure message projection 只增 contentGeneration;普通 tail append 不增这两个 generation。消息缓存可以只扩新 nodes,已有内容改写才 rebuild。generation 不是事件总数。
6.3 deriveMessages 逐句理解¶
Session.deriveMessages 取 surface.nodes/contentGeneration;generation 与缓存不同,清空 derived 数组和已投影节点计数;只对未处理 nodes 调用 deriveEventMessage;null 消息不加入;更新 derivedNodes;最后返回 fresh [...derived]。数组永远是新快照,消息对象可共享深冻结身份。方法全文。
deriveEventMessage 先查 projectedMessages map,再按 event type 取消息:user/message 原样返回,system/developer/assistant 若空 content 则 null,tool/result 返回 tool message,attempt/其他事件 null。这里不会给所有注入 user message 再套一个统一 <context>;内容 framing 属于生产者,source/metadata 保留解释来源。纯投影。
6.4 两种 projection 的边界¶
| 类型 | 注册服务 | 输出 | 会改变模型消息吗 |
|---|---|---|---|
| SessionMessageProjection | ctx.sessions.registerMessageProjection |
旧 seq→不可变 message 更新 | 会,必须由 durable event 驱动 |
| ProjectionDefinition | ctx.sessionProjections.register |
一个 typed state + 可选 wire view | 不直接改变 model surface |
前者保持 message identity 与 node membership,用于图片 offload 等内容改写;project 必须先验证完整 durable decision,不能原地改 input。未知 required message-projection event 没对应解释器时明确失败;已用解释器移除后 _assertProjections 拒绝后续 derive。reader 只加载 core Session 不足以复原所有插件改写。消息解释器契约。
后者用于 inbox、goal、turnBoundary、team、UI 状态。它的 apply 是同步 pure transition,不相关事件必须返回同一 state reference。wire.view 也要复用对象身份才能避免无意义发布;Object.is 是 change gate,不是 JSON deep comparison。领域 registry 及类型。
6.5 领域投影的 drive、watermark 与恢复¶
registry 订阅 session/event 一次,每个 accepted event 驱动所有注册单位。cell 保存 state、observedSeq、前后 views。新 key 或老 Session 首次触碰会从 init 折 full log;已有 cell 补连续 tail。即便 state 不变也前进 watermark,snapshot 的 asOfSeq 因而能代表一致 cut。
stateOf 服务端读 state;snapshot 强制 materialize 全部 registered cell,schema 验证 wire view;cachedSnapshot 不补日志,只给已有 cache 的最低共同 watermark,因此只是提示,不是完整 baseline。
同一个 key 可能由多个 Agent preset 注册,refs 避免一个 Agent 卸载把所有 Session 的 key 删除。当前实现只比较 stateVersion:同 key 不同版本会拒绝;版本相同则增加 refs,继续使用第一次注册的 definition,不比较后续 definition 的函数、schema 或对象身份。注册方必须自行保证同 key/版本的语义一致,不能指望框架检出同版本冲突。最后 registrant 移除后 snapshot 表示 capability absence。registry 注册与读。
checkpoint 返回 (key→{ver,seq,val}),val structuredClone,不能借出 live mutable state。restoreFloor 不是总取 watermark+1:它取最低需要起点再退一条 anchor,以确认 stored log 没在 crash repair 后缩短。版本错、watermark 超 log end、缺 row 等都使 row 无效;若当前只有 baseSeq>0 的 suffix,不能从 init 假装已看过前缀,抛错要求从 0 重读。checkpoint/restore。
所以 stateVersion 是投影缓存算法和字段版本,不是 Session structural version;缓存永远不是真实日志的替代品。
6.6 SystemPrompt 的全部贡献类型¶
SystemPrompt 管全局和 scoped layer;同名 section/context/variable 由最近 scope 遮蔽,tools providers 则合并贡献。section 有确定 order,context 有 order,variable 名必须 [a-z][a-z0-9_]*。scope suppressRuntimeContext 屏蔽动态快照,不会撤销 enforce policy 的服务本身。注册函数。
assemble 顺序:
- 找 scope chain,判断 global/ancestor suppressor。
- 全局 variables 后远到近 scoped variables,最近值优先。
- 按名字 merge sections/contexts;收全局和匹配 scoped tools providers。
- 工具 parameters structuredClone,仅投影 name/description/parameters/deferLoading。
- section order 再 code-unit name 排序;tool 默认按名字排序,显式 toolOrder 必须含
<unlisted-tools>且不能未知名称。 - 求 section/context 的动态 text,形成
{sections,contexts,tools,variables}。 system-prompt/assemblewaterfall 改写 assembly。- complete section 若有则最后恢复为唯一 sections,suppressor 若有则最后恢复 contexts=[],不能被 waterfall 意外取消。
complete section 同时激活超过一个会报错;未知配置工具名在加载时未必能确定,所以 assemble 才报。工具 schema 顺序确定能稳定 header equality 和 provider cache 前缀,但不保证网络缓存一定命中。assemble 全文。
6.7 renderPrompt 与严格变量¶
section.text 为函数则先在 assembly 求值,renderPrompt 再插值。interpolate:false 的 section 保留字面文本;其他 section 遇到 unknown/undefined/malformed {{name}} 就错误,空 section 被丢弃,以双换行连接。不闭合的 lone {{ 且后面无 }} 当字面 prose;替换出来的字符串不会再次扫描。不能把 unknown variable 自动变空,以免关键身份/目录配置静默丢失。渲染实现。
context 使用 renderContextSections 一次插值、保留 named sections,再 joinContextSections 包含“本快照覆盖此前快照”的 framing;这是 user-role input,不是系统头的一部分。
6.8 RuntimeContextProjection:变化才发一条候选¶
恢复时从 newest event 向前找到 retained runtime-context user/message 并检查其是否还在 surface。新 assembly 的全部 rendered context 与 retained text 相同则不发;首次为空且没有历史则不发;从非空变空发明确 CLEARED 文本;改了才返回一个尚未提交的 UserMessage。它的 source 是 typed object:sections.length > 0 时为 {kind: 'runtime-context', form: 'snapshot', sections},保存贡献归属;sections 为空时仅为 {kind: 'runtime-context'},清空 marker 不带已经消失的贡献。这不是字符串 source,也不是每条更新都有 form/sections。
只有 loop 入步和真实 route 准备成功后,候选才在 user/message 中提交。projection.project 本身不是 append。若消息被 replacement shadow,事件监听把 retained 置 null,下次可重新发当前快照。完整实现。
6.9 系统提示词:由实际 prepared route 决定承认方式¶
SystemPromptProjection.project(rendered,{inHistory,startsSeries}) 找当前所有 surviving system nodes。一个都没有时,即使 rendered='' 也预留第一个 system 节点。空节点不派生 wire message,但保证系统头的位置明确。
| 条件 | 操作 |
|---|---|
| 支持 in-history,系列继续,rendered 非空且与最新有效 prompt 不同 | append 新 system/message 到历史后面 |
| incapable route、显式新系列、surface content 改写或不支持 tool update 的工具变化 | 将非空 tail system nodes 清空;必要时 replace head |
| rendered 为空 | 清空所有有效旧 system text,不能恢复旧指令 |
| 当前有效文本未变 | 无需新事件(但 consolidation 还可能清 tail) |
这里用本 attempt 的 prepareCall 返回能力,而不是上一 request/context 快照。模型/工具切换能改变 route 能力,旧 metadata 不能做本次 prompt admission 的依据。系统投影全部分支。
6.10 request/header、developer updates、toolHistory¶
header 只有 config、adapter-default marker、tools;system prompt 已经在 surface,header.system 被明确拒绝。canonicalHeader 将空可选项省略;headerEquals config 按字段、tools 按顺序 JSON schema 比较。
buildRequest 在首次、resume、header change、series break 时记完整 header;只换 prompt 并在 capable history append 时可能无需新 header。增加/移除 tool 名生成 developer message;addition 指向当前 headerSeq,历史定义从该 header 读取,不在 tool-addition 内重复 inline schema。header 与 developer 更新。
ToolHistoryProjection 保存系列 baseline、历史定义和 developer additions。删掉工具不删除已声明历史定义;名字重新注册不同定义会重开系列以丢弃旧声明前缀复用。某些旧 Session 只有 header 无 matching updates 时 snapshot fallback 到 active tools。toolHistory 是 provider projection 的输入,和 request.tools 当前集合不能互换。工具历史完整折叠。
6.11 retry:冻结每个 request,不是永久重用一个 request¶
一个 step 的 assembly 和 admitted messages 只决定一次;retry 返回后不重复 assembly/pre-step,也不重加 users。但 每次 attempt 都重新 prepareRequest、reconcile system、buildRequest,可以改变路由和 adapter defaults;header/context 只在有变化时新记录。
buildRequest 深冻结 canonical header、所有 derived message 身份(WeakSet 避免同 Agent 重复 freeze)、fresh messages 数组和 envelope;AbortSignal 保持 live。不会为了 freeze message 把借来的 restored event wrapper 一并冻结。prepared adapter 一次绑定跨 default resolution、header logging 和实际 dispatch,避免同 attempt 中 provider 替换造成 config 来自 A、调用却去 B。准备/冻结边界。
自己的插件若要额外 model-facing 内容,不要在 llm/stream 中偷偷 push 一条未记录 message。使用 logged user input 或专门的 pure message projection;如果必须改变消息系列,保留或明确设置 startsRequestSeries。实验应比较实际 mock adapter 收到的 message 与同 prefix 的日志重建结果。