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:
@@ -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
|
||||
|
||||
@@ -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' }] }]
|
||||
|
||||
Reference in New Issue
Block a user