Files
deepseek-harness/docs/rfc/implemented/feature/2026-07-07-session-prefix.md
Tianyi Cui 972e7cc77d Merge remote-tracking branch 'origin/master' into codex/trim-ai-prose
# 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
2026-07-14 00:40:36 +08:00

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 is EpochHeader.messagePrefix on the instance's anchoring request/header snapshot — 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) recomputes messagePrefix + boundary derivation against 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(), a tools/post-execute decision's additionalContext, prompt-submit additionalContextthe interception-seams RFC), each a durable context/message paid 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-step carries a sessionPrefix parameter and CompactService.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/after slots recomputed every step (the shape first proposed: a waterfall firing on every request, contributing frozen before messages ahead of the history and fresh after messages behind it) — rejected. A per-step before recompose invites silent drift — nothing anchors it to the log short of logging a header delta per step — and an after slot 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 single system string, 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-step and CompactService.compactIfNeeded carry a sessionPrefix parameter: 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 after slot 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-delta messagePrefix arm (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.