refactor(agent): rename agent/request-messages to agent/request-advice

tianyicui's review: the seam name did not say what the event or its
types do. 'advice' reads both ways — advisory content for the model,
and AOP before/after advice woven around a join point (here the
derived history) without modifying it — so RequestAdvice.before/after
are self-describing. Types follow: RequestAdvice / RequestAdviceContext;
the logged EpochHeader fields keep their positional names
(messagePrefix/messageSuffix).

Also sharpens the core.md wording the review flagged as ambiguous:
before-advice sits in front of the ENTIRE derived history, directly
after the system slot (the conventional home for session-stable openers
— an AGENTS.md digest, a skills catalog), after-advice follows the
history's last message. Catalogs and doc graphs regenerated.
This commit is contained in:
Yichen Jiang
2026-07-08 10:05:34 +08:00
parent 731ae2443c
commit e97fffeab7
15 changed files with 117 additions and 108 deletions

View File

@@ -22,11 +22,11 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and any request-only messages (`messagePrefix`/`messageSuffix`, below) — is logged session state, in canonical form (empty system/tools/message arrays ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole; `messagePrefix`/`messageSuffix`: replaced whole, an empty array encoding the transition to absence). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds.
**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → `agent/pre-step` (compaction's surface mutations land before derivation) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the `agent/request-messages` waterfall — request-ONLY `before`/`after` messages framing the boundary snapshot (a frozen empty seed, contributions returned as an extension of `next()`; the per-request advisory channel: content the model must see now that must NOT become history — a skills catalog, an environment reminder) — → the header event the request owes the log, carrying those contributions as `messagePrefix`/`messageSuffix` (no session event carries them, so the header is their only durable record) → build `GenerateOptions` from `messagePrefix + snapshot + messageSuffix` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's only in-process bookkeeping is one boolean: whether this instance has logged its anchoring snapshot.
**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → `agent/pre-step` (compaction's surface mutations land before derivation) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the `agent/request-advice` waterfall — request-ONLY `before`/`after` messages framing the boundary snapshot (a frozen empty seed, contributions returned as an extension of `next()`; the per-request advisory channel: content the model must see now that must NOT become history — a skills catalog, an environment reminder) — → the header event the request owes the log, carrying those contributions as `messagePrefix`/`messageSuffix` (no session event carries them, so the header is their only durable record) → build `GenerateOptions` from `messagePrefix + snapshot + messageSuffix` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's only in-process bookkeeping is one boolean: whether this instance has logged its anchoring snapshot.
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, nothing can enter this request past the boundary: an `agent.inject()` from an `agent/request` listener (or any concurrent task, or a `session/event` listener firing on `step/start` itself) lands in the log after the boundary and joins the NEXT request. For waterfall-window appends this matches the prior loop (it also derived before its waterfall); for a synchronous `step/start` listener it is a deliberate change — such a listener could previously reach the current request — and `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix`, then the boundary derivation, then its `messageSuffix` — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/request-messages` seam's contributions enter only because the header event records them first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step.
**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix`, then the boundary derivation, then its `messageSuffix` — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/request-advice` seam's contributions enter only because the header event records them first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step.
### The MiniCode shape: adopted, with the provenance arrow inverted
@@ -44,7 +44,7 @@ What survives from `LLMClient`: the conversation is maintained, not rebuilt —
## Consequences
- A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event.
- Choosing between the advisory channels is a change-frequency cost decision, and the seam does not hide it: an `agent/request-messages` contribution rides the request's uncached tail and is re-tokenized at full price on every request it appears in (a `before` contribution instead extends the cacheable prefix at zero marginal cost while stable, but a mid-session change invalidates the provider cache for the entire history after it), whereas an `inject()`ed `context/message` is paid once and prefix-cached thereafter at the price of accumulating durably in history and the log. Route session-frozen content to `before`, low-frequency change notices to `inject()`, and reserve `after` for small, frequently refreshed state snapshots where a durable chain of stale copies would bloat the log and mislead the model.
- Choosing between the advisory channels is a change-frequency cost decision, and the seam does not hide it: an `agent/request-advice` contribution rides the request's uncached tail and is re-tokenized at full price on every request it appears in (a `before` contribution instead extends the cacheable prefix at zero marginal cost while stable, but a mid-session change invalidates the provider cache for the entire history after it), whereas an `inject()`ed `context/message` is paid once and prefix-cached thereafter at the price of accumulating durably in history and the log. Route session-frozen content to `before`, low-frequency change notices to `inject()`, and reserve `after` for small, frequently refreshed state snapshots where a durable chain of stale copies would bloat the log and mislead the model.
- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replace node), a real prompt/tool change (`request/header-delta`), a config switch (ditto), a process boundary with drift (`'resume'` snapshot differing from its predecessor). The provider's own reasoning-content exclusion is managed server-side.
- The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam.
- Tool-result trimming (planned) needs no new mechanism: a logged single-node surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.