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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user