feat(agent): add the agent/request-messages request-only message seam

A new waterfall near request construction lets plugins contribute
request-ONLY messages framing the derived history: RequestMessages
{ before, after } with a frozen empty seed, fired inside the open step
after the agent/request config waterfall, so the step/start boundary
snapshot and its same-sync-frame invariant are untouched. The request
becomes messagePrefix + boundary snapshot + messageSuffix.

Contributions never enter session history — deriveMessages() is
unchanged — so the request header is their durable record:
EpochHeader gains messagePrefix/messageSuffix (canonical absence for
empty arrays), request/header-delta replaces either array whole with
an empty array encoding the transition back to absence, and the
dev-mode reconstruction cross-check now expects the folded header's
framing around the boundary derivation.

This is the seam for per-request advisory context that must be
model-visible now without becoming durable history (a skills catalog,
an environment reminder), keeping the base system prompt
workspace-independent and provider prefix caches stable. The docs
carry the channel cost model: session-frozen content belongs in
before, low-frequency change notices belong in durable history via
inject() (paid once, prefix-cached thereafter), and after is reserved
for small frequently-refreshed state snapshots re-paid on every
request they ride. No shipped producer yet, so ACP snapshot fixtures
are byte-identical.
This commit is contained in:
Yichen Jiang
2026-07-07 19:42:30 +08:00
parent e477d76199
commit 17bd71e530
20 changed files with 553 additions and 119 deletions

View File

@@ -367,8 +367,11 @@ 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 derivation over the log prefix strictly before the
// in-flight step's `step/start` (the reconstruction boundary). Compared
// - messages: the folded header's request-only messages (messagePrefix /
// messageSuffix — the `agent/request-messages` contributions, logged on
// the header because no session event carries them) framing 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
// code with zero shared state, so the live cache under test cannot vouch
// for itself. Boundary-correct by construction: content appended after
@@ -408,18 +411,22 @@ export function apply(ctx: Context, config: Config = {}): void {
if (boundary === -1) {
throw new InvariantError('a loop-built request with no step/start in its session log')
}
const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)))
// JSON equality is sound here: both sides are structuredClones produced by
// the same projection code path, so key insertion order matches when the
// values do.
if (JSON.stringify(options.messages) !== JSON.stringify(rebuilt.deriveMessages())) {
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
}
const header = foldRequestHeader(events)
if (header === undefined) {
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
// logs the header event BEFORE dispatch, so the fold already covers this
// request's contributions. 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 ?? []]
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)`)
}
const headerMatches = options.model === header.config.model
&& options.system === header.system
&& options.temperature === header.config.temperature

View File

@@ -707,6 +707,22 @@ 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 () => {
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…
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 })
expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/)
})
it('rejects a frozen request whose messages diverge from the boundary derivation', async () => {
const { ctx, session, boundary } = await requestSetup()
const messages = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]