fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001)

Codex round 1 CBR-001: a head-anchored compaction checkpoint was
mis-classified by the log-position step-alignment scan, so a second
auto-compaction over a checkpoint-headed surface silently failed.

Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a
`replace` op lands a checkpoint at a high log seq whose SURFACE position
is the head — its log neighbours (the open step's assistant/message) are
not its surface neighbours, so the forward scan wrongly reported mid-step.

Fix, per the agreed direction:
- Replace the two log-position predicates with one surface-anchored
  helper `isToolPairingBalanced(nodes, events, beforeSeq)` in
  `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is
  balanced when no unanswered tool-call precedes it on the surface; a
  region is collapsible iff both edges are balanced cuts. The open-tail
  and free-node cases fall out of the same counter. It also throws on a
  corrupt surface (a tool/result with no matching call).
- Move compaction off the in-step seam to a new "pre-step" seam fired
  after turn/start and before step/start, so a compaction's log-only
  compact/* records and its replacement node land cleanly OUTSIDE any
  step (the honest structure crash-safety relies on). Renamed the event
  agent/pre-request → agent/pre-step and switched its dispatch from
  parallel → serial (listeners mutate the surface as a side effect;
  serial isolates them so concurrent appends can't interleave). Extended
  the catalog generator to accept @mode serial.

Regression coverage: a real-loop test driving an auto-compaction asserts
the landed checkpoint is a balanced cut on both sides; unit tests pin the
checkpoint case, the mid-step injection case, multi-call steps, and the
corrupt-surface guard. Proven red on the old log-position logic.
This commit is contained in:
Hypatia May
2026-06-26 13:51:01 +08:00
parent cec32faa4e
commit d6da8ca29a
17 changed files with 912 additions and 448 deletions

View File

@@ -181,30 +181,37 @@ declare module 'cordis' {
// ---- interception seams (waterfall) ----
/**
* Awaited surface-mutation checkpoint, fired BEFORE the step's message
* history is derived (and thus before {@link agent/request}). The loop
* awaits `ctx.parallel('agent/pre-request', …)` after assembling the system
* prompt but before `session.deriveMessages()`, then derives ONCE from
* whatever the surface now holds. This is where compaction belongs: it
* mutates the session surface in place (shadowing an older range with a
* summary node), and the single subsequent derive reflects the mutation —
* so there is no double-derive and no listener can see (or be expected to
* act on) an assembled `messages` array that does not exist yet.
* Awaited pre-step surface-mutation checkpoint, fired once per step AFTER
* `turn/start` (and after the prior step closed) but BEFORE this step's
* `step/start` — so anything a listener appends lands OUTSIDE the step,
* between `turn/start`/`step/end` and the upcoming `step/start`. `step` is
* the number of the step about to start. The loop awaits
* `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then
* opens the step and derives the request history ONCE from whatever the
* surface now holds. This is where compaction belongs: it mutates the session
* surface in place (shadowing an older range with a summary node) with its
* log-only `compact/*` records cleanly outside any step, and the single
* subsequent derive reflects the mutation — so there is no double-derive and
* no listener can see (or be expected to act on) an assembled `messages`
* array that does not exist yet.
*
* Awaited (parallel), not a waterfall: a listener mutates the surface as a
* side effect; there is nothing to transform or veto, but the loop must wait
* for the mutation to complete before deriving. `system`/`model` are the
* assembled values a listener needs to measure pressure (system counts
* toward the budget) and to summarize (the model). `signal` cancels any
* in-flight work a listener starts (e.g. a summarization model call).
* @mode parallel
* Serial (awaited, in registration order, no veto), not a waterfall: a
* listener mutates the surface as a side effect; there is nothing to
* transform or veto, but the loop must wait for the mutation to complete
* before opening the step and deriving, and serial isolates listeners from
* each other (one finishes its surface append before the next runs).
* `system`/`model` are the assembled values a listener needs to measure
* pressure (system counts toward the budget) and to summarize (the model).
* `signal` cancels any in-flight work a listener starts (e.g. a summarization
* model call).
* @mode serial
*/
'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise<void> | void
'agent/pre-step'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise<void> | void
/**
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
* model call (hooks, model switching, tool filtering, …). Call `next()` to
* delegate, or return without it to short-circuit. For surface mutation that
* must precede history derivation (compaction), use {@link agent/pre-request}
* must precede history derivation (compaction), use {@link agent/pre-step}
* instead — by the time this fires, `options.messages` is already derived.
* @mode waterfall
*/