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

@@ -367,9 +367,9 @@ export function apply(ctx: Context, config: Config = {}): void {
// hand-built one-shot (compaction summarize) is unfrozen and skipped — must
// be EXACTLY what the session log reconstructs:
//
// - messages: the folded header's request-only messages (messagePrefix /
// messageSuffix — the `agent/request-advice` contributions, logged on
// the header because no session event carries them) framing the
// - messages: the folded header's session prefix (messagePrefix — the
// `agent/session-prefix` product, logged on the header because no
// session event carries it) followed by the
// derivation over the log prefix strictly before the in-flight step's
// `step/start` (the reconstruction boundary). The derivation is compared
// against a FRESH Session built over that prefix — the same projection
@@ -416,13 +416,13 @@ export function apply(ctx: Context, config: Config = {}): void {
throw new InvariantError('a loop-built request with no request/header event in its session log')
}
const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)))
// The reconstruction equation: the folded header's request-only messages
// frame the boundary derivation (prefix + derived + suffix) — the loop
// The reconstruction equation: the folded header's session prefix, then
// the boundary derivation — the loop
// logs the header event BEFORE dispatch, so the fold already covers this
// request's contributions. JSON equality is sound here: both sides are
// request's prefix. JSON equality is sound here: both sides are
// structuredClones produced by the same projection/build code path, so key
// insertion order matches when the values do.
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages(), ...header.messageSuffix ?? []]
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
}

View File

@@ -707,19 +707,18 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('expects the folded header\'s request-only messages to frame the derivation (prefix + derived + suffix)', async () => {
it('expects the folded header\'s session prefix ahead of the derivation (prefix + derived)', async () => {
const { ctx, session, boundary } = await requestSetup()
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
const suffix = { role: 'user' as const, content: [{ type: 'text' as const, text: 'trailing note' }] }
session.append('request/header-delta', { messagePrefix: [prefix], messageSuffix: [suffix] })
// The framed request matches the fold…
const framed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary, suffix]), sessionId: session.id })
expect(() => { dispatch(ctx, framed) }).not.toThrow()
// …a request that DROPPED the logged framing diverges…
session.append('request/header-delta', { messagePrefix: [prefix] })
// The prefixed request matches the fold…
const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })
expect(() => { dispatch(ctx, prefixed) }).not.toThrow()
// …a request that DROPPED the logged prefix diverges…
const bare = Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })
expect(() => { dispatch(ctx, bare) }).toThrow(/diverges from the boundary derivation/)
// …and so does one that misplaced it (suffix sent as a prefix).
const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([suffix, prefix, ...boundary]), sessionId: session.id })
// …and so does one that misplaced it (prefix sent after the history).
const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })
expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/)
})