8.9 KiB
RFC: The session prefix — request-only messages in front of the derived history
Status: implemented
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 RFC: 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 (packages/core/agent-loop/src/loop.ts) 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 RFC already owns for the request's non-history half, so no new session event exists. The dev invariant (dsh-invariants) recomputesmessagePrefix + boundary derivationagainst every loop-built request; an unlogged prefix cannot reach the wire. - 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 RFC), each a durablecontext/messagepaid once and prefix-cached thereafter. - Composed before the pressure gate. Composition precedes the instance's first
agent/pre-step, and the seam hands the composed value through:agent/pre-stepcarries asessionPrefixparameter andCompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)counts it in its token-pressure estimate — a gate reading the previous instance's folded prefix instead would under-gate a resumed or forked instance whose contributor grew, skipping compaction and shipping an over-window first request. A composition interrupted by a cancel/dispose landing inside the waterfall 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
Unit — interception.spec.ts pins compose-once across turns and steps (one composition and no changed headers), canonical prepend ordering, empty-prefix omission from the header, the frozen seed (in-place push throws), held-reference mutation immunity, and composition-precedes-pre-step with the seam receiving the composed value; cancel.spec.ts pins cancel/dispose landing inside the composition window and the discard-and-recompose stale-cache guard; dsh-session header tests cover canonical prefix snapshots and latest-snapshot folding; dsh-invariants tests pin the messagePrefix + derivation equation; dsh-compact-basic tests pin that the pressure estimate counts the handed prefix. Snapshot — the acp-snapshot normalizer scrubs header prefixes to count-preserving {{messagePrefix}} tokens (unit-covered in dsh-acp-snapshot); header content itself is pinned per the pinned-header scenario RFC, and the example tree loads no prefix contributor, so live goldens stay prefix-free. e2e — none prefix-specific: the seam is provider-independent and deterministic; the with-key cache measurement in request-cache.e2e.ts already proves the cacheable-prefix economics the design rests on.
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 full 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. - Compose lazily at the first request and let compaction read the folded header (the shape as first merged) — superseded in review: the fold matches the live prefix only from the instance's second request on, so on a resumed/forked instance's first step the pressure gate read the PREVIOUS instance's prefix and could under-gate. Composing before the first pre-step and handing the live value through the seam makes the estimate exact at every step.
- A dedicated session event carrying the prefix — rejected: request headers are the request's non-history record by design; a second event would be a second home for the same fact.
Consequences
agent/pre-stepandCompactService.compactIfNeededcarry asessionPrefixparameter: every pre-step listener and compaction backend sees the real per-instance value (all in-repo implementations updated in the same change, per the pre-release stance).- 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.