fix(agent-loop): compose the session prefix before pre-step; hand it to the pressure gate
ds-review-bot critical (follow-up): on the first step of a resumed or seeded/forked instance, auto-compaction ran before runStep composed this instance's prefix, so the gate read the PREVIOUS instance's logged prefix from the header fold — a contributor that grew across resume/fork (skills added, AGENTS.md grown: exactly the environment-dependent case) could under-gate and ship an over-window first request. The loop now composes agent/session-prefix before the instance's first agent/pre-step (still once per instance; runStep just reads the cache), and agent/pre-step carries the composed prefix to its listeners. CompactService.compactIfNeeded gains the sessionPrefix parameter; BasicCompactService.estimatePressure gates on the handed value — the header-fold read is gone, so the estimate is exact at every step including a resumed/forked instance's first. Composition moving before the boundary snapshot also means a composing listener's session append now joins the CURRENT request (documented on the seam). New coverage: composition precedes pre-step and the seam receives the composed prefix; cancel and disposal landing inside the composition window drop the step cleanly; the compact gate test hands the prefix directly.
This commit is contained in:
@@ -43,9 +43,9 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne
|
||||
|
||||
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
|
||||
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
|
||||
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step.
|
||||
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step; carries the assembled system prompt and the instance's composed session prefix so a token-pressure gate counts everything the request will carry.
|
||||
- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event
|
||||
- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily on its first request; the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
|
||||
- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily before its first pre-step (so pressure gates see this instance's real prefix, never a previous instance's logged one); the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
|
||||
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
|
||||
- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
|
||||
|
||||
|
||||
@@ -333,21 +333,28 @@ declare module 'cordis' {
|
||||
* value; this event is typed and documented as `void`, so listeners must not
|
||||
* return a semantic veto value. `fullSystemPrompt` is the assembled prompt a
|
||||
* listener needs to measure pressure (the system prompt counts toward the
|
||||
* budget). `signal` cancels any in-flight work a listener starts (e.g. a
|
||||
* budget), and `sessionPrefix` is the instance's composed
|
||||
* {@link agent/session-prefix} product for the same reason — every request
|
||||
* carries it in front of the derived history, and it is composed BEFORE
|
||||
* this seam fires precisely so a pressure gate counts the prefix the
|
||||
* request will actually send (never a stale logged one). `signal` cancels
|
||||
* any in-flight work a listener starts (e.g. a
|
||||
* summarization model call).
|
||||
* @param agent - the agent about to open the step.
|
||||
* @param turn - the already-open turn this step belongs to.
|
||||
* @param step - the number of the step about to start.
|
||||
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
|
||||
* @param sessionPrefix - the instance's frozen session prefix, for the same measurement.
|
||||
* @param signal - aborts in-flight listener work when the turn is torn down.
|
||||
* @mode serial
|
||||
*/
|
||||
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
|
||||
// is its only consumer, so a wide event carries a string just one listener
|
||||
// TODO: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic
|
||||
// per-step seam — compaction
|
||||
// is their only consumer, so a wide event carries payloads just one listener
|
||||
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
|
||||
// prompt provider, or move token-pressure measurement behind a
|
||||
// compaction-specific seam instead of the shared pre-step checkpoint.
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Waterfall: decide what happens to ONE drained queued message before it
|
||||
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
|
||||
@@ -389,13 +396,18 @@ declare module 'cordis' {
|
||||
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
|
||||
* front of the ENTIRE derived history (directly after the provider's
|
||||
* system slot) on every request this loop instance sends. Fired ONCE per
|
||||
* loop instance, lazily on its first request-building step; the composed
|
||||
* loop instance, lazily before its first step's {@link agent/pre-step}
|
||||
* seam — BEFORE the pre-step so a token-pressure gate (compaction) counts
|
||||
* the prefix this instance will actually send, never a previous
|
||||
* instance's logged one. The composed
|
||||
* result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the
|
||||
* instance's anchoring `'initial'`/`'resume'` header snapshot, and reused
|
||||
* verbatim for every subsequent request — never recomputed mid-session,
|
||||
* so the provider prefix cache holds by construction (a process restart
|
||||
* or `ctx.agents.resume()` is a new instance: it recomposes, and any
|
||||
* drift lands attributably on the `'resume'` snapshot).
|
||||
* drift lands attributably on the `'resume'` snapshot). Composition runs
|
||||
* outside the step, before the boundary snapshot: a composing listener's
|
||||
* session append joins the CURRENT request's derived history.
|
||||
*
|
||||
* This is the home for session-stable openers the model must always see
|
||||
* but that must NOT become durable history — a skills catalog, an
|
||||
|
||||
Reference in New Issue
Block a user