The session prefix — request-only messages in front of the derived history
会话前缀——派生历史之前的仅请求消息
A plugin often owns a session-stable opener the model must always see — a skills catalog, an AGENTS.md digest, a workspace baseline. Before this seam the harness offered two homes, and both are wrong for that content. The system prompt is one rendered string: message-shaped content (a user-role `<system-reminder>` envelope, a multi-message primer) does not fit it, and providers weight conversation messages differentl
English
Problem
A plugin often owns a session-stable opener the model must always see — a skills catalog, an AGENTS.md digest, a workspace baseline. Before this seam the harness offered two homes, and both are wrong for that content. The system prompt is one rendered string: message-shaped content (a user-role <system-reminder> envelope, a multi-message primer) does not fit it, and providers weight conversation messages differently from system text. Durable history (agent.inject(), a context/message at session start) makes the opener permanent: every deriveMessages() consumer replays it, the compaction retention walk owns it, forks bake it in stale, and a resume cannot refresh it — a catalog captured at session birth outlives the world it described.
The obvious third option — let a plugin edit the request's messages on the way out — is banned by the reconstructable-requests Agent Note: every loop-built request is a pure function of the session log, so whatever channel carries the opener must log exactly what it sends. What was missing was a request-only message channel with a durable record.
Decision
agent/session-prefix is a waterfall on the agent event map (packages/core/agent/src/types.ts): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, [mine, ...await next()], which yields registration order on the wire). The loop (agent-loop source) fires it once per loop instance, lazily before the instance's first agent/pre-step; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends (wire order).
Three properties carry the design:
- Request-only, header-logged.
deriveMessages()never returns the prefix; its one durable record isEpochHeader.messagePrefixon the instance's anchoringrequest/headersnapshot — the channel the reconstructable-requests Agent Note already owns for the request's non-history half, so no new session event exists. Thedsh-agent-loop/invariantcompanion recomputesmessagePrefix + boundary derivationagainst every loop-built request; an unlogged prefix cannot reach the wire when that contribution is enabled. - Frozen per instance. Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or
ctx.agents.resume()is a new instance: it recomposes, and any drift lands attributably on the'resume'header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (agent.inject()or tool/prompt-submitadditionalContexts— the interception-seams Agent Note), each a durablecontext/messagepaid once and prefix-cached thereafter. - Exact in the durable request envelope. Composition precedes the instance's first
agent/pre-stepand request boundary. The first routed request logs the current prefix on its header, so post-step token pressure reads the exact prefix together with the actual prompt, tools, and routed model; no compaction-only parameter is carried through the generic pre-step seam. A composition interrupted by cancel/dispose is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal.
Because composition runs before the boundary snapshot, a composing listener's session append joins the CURRENT request's derived history. Compaction structurally cannot touch the prefix (or the system prompt): it rewrites surface nodes, and header state never enters the surface.
Testing
Interception tests pin compose-once reuse without changed headers, prepend order, empty-prefix omission, immutability, composition before pre-step, and the prefix on the routed header; cancellation tests pin discard and recomposition. Session, invariant, token-meter, and compaction tests cover header round trips, request reconstruction, and durable prefix-aware pressure accounting. Snapshot normalization preserves prefix counts, while the pinned-header scenario owns content and the default example remains prefix-free. The provider-independent seam needs no dedicated e2e; the with-key request-cache e2e covers its cache economics.
Alternatives considered
- Per-request
before/afterslots recomputed every step (the shape first proposed: a waterfall firing on every request, contributing frozenbeforemessages ahead of the history and freshaftermessages behind it) — rejected. A per-stepbeforerecompose invites drift that must be logged as a full changed header, and anafterslot sits behind the growing history, so its tokens re-pay on every request and everything after it is uncacheable. Measured against the alternatives, every current update pattern is served cheaper by a durable append (paid once, cache-read thereafter), and the only content with no home was the session-stable opener — which wants freezing, not recomputation. - A system-prompt section (
system-prompt/assemble) — rejected for this content: the assembly renders to the singlesystemstring, so message-shaped openers do not fit, and the system prompt is deliberately re-assembled per step (with a full changed header when it changes) while the opener wants instance-frozen semantics. - A durable history opener (
inject()at session start) — rejected: permanent history is the failure mode in the problem statement — replayed everywhere, compactable, stale across resumes. - Compose per turn instead of per instance — rejected: a turn-boundary recompose either desyncs silently from the log or forces a changed header, and it busts the provider cache exactly as often as it fires; the legitimate refresh point is the instance boundary, where the
'resume'snapshot already records drift attributably. - Carry prompt/prefix through
agent/pre-stepfor provisional pressure — rejected because it couples a generic lifecycle seam to one consumer and still misses later request routing and tools; post-step replay reads every request-envelope field from its durable routed header. - A dedicated session event carrying the prefix — rejected: the header events are the request's non-history record by design; a second event would be a second home for the same fact and another codec to keep total.
Consequences
agent/pre-stepstays a generic(agent, turn, step, signal)checkpoint. Compaction receives no prefix parameter;ctx.tokenMeterfolds the prefix from the canonical routed header at post-step.- A contributor whose content changes mid-session is not re-read until the next instance — by design. A deployment needing mid-session catalog updates routes the change notice through the append-only history channels and pays one durable
context/message. - The dropped
afterslot leaves no request-only channel near the request tail; nothing in the repo needs one, and adding it back would re-open the every-step re-pay cost the design exists to avoid. - An empty composition is canonical absence: no-contributor deployments log no extra header bytes and their requests are the bare derivation.
中文
问题
插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在引入本 seam 之前,harness 为这类内容提供了两个归属位置,但两者都不合适。系统提示词是一个渲染后的单一字符串:消息形态的内容(user 角色的 <system-reminder> 信封、多消息引导序列)放不进去,而且提供方对会话消息和系统文本的权重处理不同。持久化历史(agent.inject()、会话启动时的 context/message)使开场内容变为永久:每个 deriveMessages() 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会将其以陈旧状态固化,恢复也无法刷新它——会话诞生时捕获的目录会比它所描述的世界活得更久。
显而易见的第三种选项——让插件在请求发出途中编辑 messages——被可重建请求 Agent Note禁止:每个由循环构建的请求都是会话日志的纯函数,因此无论哪个通道承载开场内容,都必须精确记录它所发送的内容。缺失的是一个带有持久记录的仅请求消息通道。
决策
agent/session-prefix 是 agent 事件映射上的一个 waterfall(瀑布式事件)(packages/core/agent/src/types.ts):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 [mine, ...await next()],在协议格式上产生注册顺序)。agent loop(智能体循环)(agent-loop 源码)在每个循环实例中触发一次,惰性地在实例首次 agent/pre-step 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于整个派生历史之前——紧接在提供方的 system 槽位之后(协议格式顺序)。
三个属性承载了这一设计:
- 仅请求,记录在 header 中。
deriveMessages()从不返回前缀;它唯一的持久记录是实例锚定的request/header快照上的EpochHeader.messagePrefix——可重建请求 Agent Note 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。配套的dsh-agent-loop/invariant对每个循环构建的请求重新计算messagePrefix + boundary derivation;启用该贡献时,未记录的前缀无法到达协议格式。 - 按实例冻结。 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的提示词缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或
ctx.agents.resume()产生新实例:它重新组合,任何漂移都可追溯地落在'resume'header 快照上。这就是本 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(agent.inject()或工具/prompt-submit 的additionalContexts——拦截 seam Agent Note),每条都是一次性支付的持久context/message,之后被前缀缓存覆盖。 - 在持久请求信封中保持精确。 组合先于实例的首次
agent/pre-step和请求边界。第一个已路由请求会把当前前缀记录在其 header 上,因此步骤后的 token 压力会将精确前缀与实际提示词、工具和已路由模型一起读取;通用的步骤前检查点 seam 不携带压缩专属参数。被取消/dispose 中断的组合会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。
由于组合在边界快照之前运行,组合监听器的会话追加会加入当前请求的派生历史。压缩在结构上不可能触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。
测试
拦截测试固定了以下行为:没有变更 header 时的组合一次复用、前置插入顺序、空前缀省略、不可变性、组合在步骤前检查点之前完成,以及已路由 header 上的前缀;取消测试固定了丢弃与重新组合。Session、不变式、token-meter 和压缩测试覆盖 header 往返、请求重建与持久前缀感知的压力核算。快照归一化保留前缀计数,固定 header 场景拥有内容,默认示例保持无前缀。与提供方无关的 seam 无需专门 e2e;带密钥的 request-cache e2e 覆盖了其缓存经济性。
曾考虑的替代方案
- 每请求
before/after槽位,每步重新计算(最初提出的形态:一个每请求触发的 waterfall,贡献冻结的before消息置于历史之前、新鲜的after消息置于历史之后):否决。每步重新组合before会引入漂移,必须记录为完整的变更 header;after槽位位于不断增长的历史之后,其 token 在每个请求中重复支付,且其后的所有内容不可缓存。对照各替代方案衡量,当前所有更新模式都能通过持久追加更廉价地满足(支付一次,此后缓存读取),而唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。 - 系统提示词分段(
system-prompt/assemble):对此类内容否决。assembly 渲染为单一system字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带完整的变更 header),而开场内容需要按实例冻结的语义。 - 持久化历史开场(会话启动时
inject()):否决。永久历史正是问题陈述中的失败模式——到处被回放、可被压缩、在恢复后仍保持陈旧状态。 - 按轮次组合而非按实例组合:否决。轮次边界的重新组合要么与日志静默失同步,要么强制产生变更 header;且它每次触发都会破坏提供方缓存。合理的刷新点是实例边界,
'resume'快照已在那里可追溯地记录漂移。 - 通过
agent/pre-step携带提示词/前缀,用于临时压力估算:否决,因为它把通用生命周期 seam 耦合到一个消费方,而且仍会遗漏更晚的请求路由和工具;步骤后的回放会从持久的已路由 header 读取请求信封的每个字段。 - 专用会话事件承载前缀:否决。header 事件按设计就是请求的非历史记录;第二个事件会为同一事实提供第二个归属,并多出一个需要保持完整的编解码器。
后果
agent/pre-step保持通用的(agent, turn, step, signal)检查点。压缩不接收 prefix 参数;ctx.tokenMeter在步骤后从规范的已路由 header 折叠前缀。- 贡献者的内容在会话中途变化时,直到下一个实例才会被重新读取——这是设计意图。需要会话中途目录更新的部署,应将变更通知路由到仅追加历史通道,支付一条持久
context/message。 - 被放弃的
after槽位意味着请求尾部附近没有仅请求通道;仓库中没有任何功能需要它,且恢复它会重新引入本设计旨在避免的每步重复支付成本。 - 空组合即为规范缺失:无贡献者的部署不记录额外的 header 字节,其请求就是裸派生。