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:
@@ -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)`)
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user