refactor(agent): replace the per-step advice seam with agent/session-prefix

Review discussion converged on the industry shape (Claude Code caches
user context per conversation; Codex separates initial context from
diffs; Kimi appends at continuation boundaries to protect prompt
caching): stable openers belong in a compose-once prefix, mid-session
changes belong in append-only history — not in a per-request slot.

agent/session-prefix fires ONCE per loop instance, lazily on its first
request-building step: the composed Message[] is deep-frozen, cached on
the transmission bookkeeping, recorded as EpochHeader.messagePrefix on
the anchoring 'initial'/'resume' snapshot, and reused verbatim for
every request the instance sends — prefix stability is structural, not
a producer discipline, and a resume recomposes with attributable drift.
The request is messagePrefix + boundary snapshot.

The per-step RequestAdvice/RequestAdviceContext surface and the
messageSuffix header field are dropped: the tail slot had no consumer,
and every current update pattern (new AGENTS.md discovered, memory
update, skills change) routes through the existing append-only history
channels — inject(), tools/post-execute additionalContext,
prompt-submit additionalContext — each paid once and prefix-cached
thereafter. The messagePrefix delta arm stays for codec totality; the
loop never produces one in practice.
This commit is contained in:
Yichen Jiang
2026-07-08 15:44:30 +08:00
parent 2cbdeb0872
commit ea4c10d753
21 changed files with 260 additions and 380 deletions

View File

@@ -51,7 +51,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
### Request-header reconstruction (`request-header.ts`)
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole request-only message arrays) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix/messageSuffix ≡ absent fields; a delta's EMPTY message array encodes the transition back to absence). `EpochHeader.messagePrefix`/`messageSuffix` are the durable record of the `agent/request-advice` waterfall's request-only contributions — the request is `messagePrefix + derived history + messageSuffix`, and `deriveMessages()` never returns them.
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it.
### Session event vocabulary (`types.ts`)

View File

@@ -22,16 +22,14 @@ type HeaderDelta = {
tools?: ToolsDelta
config?: LlmCallConfig
messagePrefix?: Message[]
messageSuffix?: Message[]
}
/**
* Normalize a header to canonical form: an empty system prompt, an empty
* tool list, and empty request-only message arrays become ABSENT fields,
* matching how requests are built (the request-build spreads skip empty
* values). Diff, fold, and comparison all operate on canonical headers, so
* "no system prompt" (and "no request-only messages") has exactly one
* representation.
* tool list, and an empty session prefix become ABSENT fields, matching how
* requests are built (the request-build spreads skip empty values). Diff,
* fold, and comparison all operate on canonical headers, so "no system
* prompt" (and "no session prefix") has exactly one representation.
* @param header - the header to normalize (not mutated).
* @returns the canonical header.
*/
@@ -41,7 +39,6 @@ export function canonicalHeader(header: EpochHeader): EpochHeader {
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {},
...header.messageSuffix !== undefined && header.messageSuffix.length > 0 ? { messageSuffix: header.messageSuffix } : {},
}
}
@@ -121,22 +118,22 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[
* writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal
* the intended header) and the loop runs to skip logging an unchanged header.
* Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is
* correctly unequal; request-only message arrays compare as canonical JSON
* (both sides come from the same build path, so key order matches when the
* values do).
* correctly unequal; the session prefix compares as canonical JSON (both
* sides come from the same build path, so key order matches when the values
* do).
* @param a - one canonical header.
* @param b - the other.
* @returns whether config, system, tools (in order), and request-only messages all match.
* @returns whether config, system, tools (in order), and the session prefix all match.
*/
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
if (!sameMessages(a.messagePrefix, b.messagePrefix) || !sameMessages(a.messageSuffix, b.messageSuffix)) return false
if (!sameMessages(a.messagePrefix, b.messagePrefix)) return false
const at = a.tools ?? []
const bt = b.tools ?? []
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
}
/** Canonical JSON equality over request-only message arrays; absence equals the empty array. */
/** Canonical JSON equality over session-prefix arrays; absence equals the empty array. */
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
}
@@ -147,7 +144,7 @@ function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] |
* ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it —
* the encoding cannot express every change (a pure tool reordering) — and
* fall back to a full `request/header` snapshot when the check fails.
* Request-only messages are replaced whole (small advisory content, not worth
* The session prefix is replaced whole (small advisory content, not worth
* diffing); an empty replacement array encodes the transition to "none".
* @param prev - the folded header the log currently implies.
* @param next - the header the next request will actually use.
@@ -161,7 +158,6 @@ export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta |
if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools)
if (!callConfigEquals(prev.config, next.config)) delta.config = next.config
if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? []
if (!sameMessages(prev.messageSuffix, next.messageSuffix)) delta.messageSuffix = next.messageSuffix ?? []
return Object.keys(delta).length > 0 ? delta : undefined
}
@@ -177,13 +173,11 @@ export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHe
const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system
const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools
const messagePrefix = delta.messagePrefix ?? prev.messagePrefix
const messageSuffix = delta.messageSuffix ?? prev.messageSuffix
return canonicalHeader({
config: delta.config ?? prev.config,
...system !== undefined ? { system } : {},
...tools !== undefined ? { tools } : {},
...messagePrefix !== undefined ? { messagePrefix } : {},
...messageSuffix !== undefined ? { messageSuffix } : {},
})
}

View File

@@ -185,14 +185,13 @@ export interface TodoItem {
/**
* The request header: everything about an LLM request besides its derived
* message history — the call configuration plus the rendered system prompt,
* tool schemas, and any request-only messages. Logged session state (the
* tool schemas, and the session prefix. Logged session state (the
* reconstructability RFC): a
* {@link SessionEventMap} `request/header` snapshot installs one, a
* `request/header-delta` amends it, and folding those events over the log
* (`foldRequestHeader`) reconstructs the header any request was built under.
* Canonical form: an empty system prompt, an empty tool list, and empty
* request-only message arrays are ABSENT fields, matching how requests are
* built.
* Canonical form: an empty system prompt, an empty tool list, and an empty
* prefix are ABSENT fields, matching how requests are built.
*/
export interface EpochHeader {
/** The conversation's call configuration (model + sampling scalars). */
@@ -202,14 +201,13 @@ export interface EpochHeader {
/** Assembled tool schemas; absent for a tool-less request. */
tools?: ToolSchema[]
/**
* Request-only messages sent BEFORE the derived history (the
* `agent/request-advice` waterfall's `before` contributions). Not session
* history — `deriveMessages()` never returns them — so the header is their
* only durable record; absent when the request carried none.
* The session prefix: request-only messages sent BEFORE the entire derived
* history (the `agent/session-prefix` waterfall's product, composed once
* per loop instance and reused for every request it sends). Not session
* history — `deriveMessages()` never returns it — so the header is its
* only durable record; absent when the instance composed none.
*/
messagePrefix?: Message[]
/** Request-only messages sent AFTER the derived history; absent when none. */
messageSuffix?: Message[]
}
/**
@@ -369,9 +367,11 @@ export interface SessionEventMap {
* Amendment to the folded {@link EpochHeader}: at least one of a
* {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement
* {@link LlmCallConfig} (four scalars — not worth diffing), or a whole
* replacement request-only message array (`messagePrefix`/`messageSuffix` —
* small advisory content, replaced whole; an EMPTY array encodes the
* transition to "none", mirroring the canonical form's absent field).
* replacement session prefix (`messagePrefix` — small advisory content,
* replaced whole; an EMPTY array encodes the transition to "none",
* mirroring the canonical form's absent field — the loop never produces
* one in practice: the prefix is composed once per instance and anchored
* by that instance's snapshot, so this arm exists for codec totality).
* Appended by the
* loop inside the step, before dispatch, when the header for this request
* differs from the fold of the log so far; the writer verifies
@@ -379,7 +379,7 @@ export interface SessionEventMap {
* falls back to a `'fallback'` `request/header` snapshot when it cannot, so
* a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}.
*/
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[]; messageSuffix?: Message[] }
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */

View File

@@ -107,38 +107,37 @@ describe('diffHeader / applyHeaderDelta', () => {
})
})
describe('request-only messages (messagePrefix / messageSuffix)', () => {
it('canonicalHeader normalizes empty arrays to absent fields', () => {
expect(canonicalHeader({ config: CONFIG, messagePrefix: [], messageSuffix: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')], messageSuffix: [msg('s')] })
describe('the session prefix (messagePrefix)', () => {
it('canonicalHeader normalizes an empty prefix to an absent field', () => {
expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
expect(full.messagePrefix).toEqual([msg('p')])
expect(full.messageSuffix).toEqual([msg('s')])
})
it('headerEquals treats absence and empty as one representation, content differences as unequal', () => {
expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true)
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false)
expect(headerEquals({ config: CONFIG, messageSuffix: [msg('a')] }, { config: CONFIG })).toBe(false)
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false)
})
it('replaces a changed prefix whole and leaves an untouched suffix alone', () => {
const prev = canonicalHeader({ config: CONFIG, messagePrefix: [msg('old')], messageSuffix: [msg('keep')] })
const next = canonicalHeader({ config: CONFIG, messagePrefix: [msg('new'), msg('more')], messageSuffix: [msg('keep')] })
it('replaces a changed prefix whole and leaves untouched parts alone', () => {
const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] })
const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] })
const delta = roundTrip(prev, next)
expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] })
})
it('round-trips framing gained from a bare header and lost back to one (empty array encodes absence)', () => {
it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => {
const none = canonicalHeader({ config: CONFIG })
const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')], messageSuffix: [msg('s')] })
const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
const gained = roundTrip(none, some)
expect(gained).toEqual({ messagePrefix: [msg('p')], messageSuffix: [msg('s')] })
expect(gained).toEqual({ messagePrefix: [msg('p')] })
const lost = roundTrip(some, none)
expect(lost).toEqual({ messagePrefix: [], messageSuffix: [] })
expect(lost).toEqual({ messagePrefix: [] })
})
it('folds framing deltas over the log like any other header amendment', () => {
const session = new Session(SessionId('fold-framing'))
it('folds prefix deltas over the log like any other header amendment', () => {
const session = new Session(SessionId('fold-prefix'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] })
session.append('request/header', { header: first, reason: 'initial' })