# Conflicts: # docs/config-catalog.md # docs/event-producer-consumer.md # docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md # examples/acp-agent/tests/acp.snapshot.ts # packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts # packages/code-runtime/code-runtime-worker/tsdown.config.ts
8.7 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(), atools/post-executedecision'sadditionalContext, prompt-submitadditionalContext— 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
Interception tests pin compose-once reuse with no header deltas, prepend order, empty-prefix omission, immutability, and composition before pre-step; cancellation tests pin discard and recomposition. Session codec, invariant, and compaction tests cover header round trips, request reconstruction, and prefix-aware pressure accounting. Snapshot normalization preserves prefix counts, while the pinned-header scenario owns content and the default example remains prefix-free. No prefix-specific e2e is needed because the seam is deterministic and provider-independent; 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 silent drift — nothing anchors it to the log short of logging a header delta per step — 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 header deltas 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 header delta per change, 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: 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-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. - The
request/header-deltamessagePrefixarm (whole-array replacement, empty array encoding transition to absence) exists for codec totality; the loop never exercises it, because the cached prefix cannot change within an instance. - An empty composition is canonical absence: no-contributor deployments log no extra header bytes and their requests are the bare derivation.