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:
@@ -5,18 +5,18 @@
|
||||
* - **Token estimation** — char/4 heuristic with per-block structural overhead.
|
||||
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
|
||||
* to a token budget, compact everything older. The cutoff is snapped forward
|
||||
* to the next step boundary so a compacted region never splits a step's
|
||||
* tool-call/result pair (an open tail step is never crossed — compaction
|
||||
* declines and retries once it closes).
|
||||
* to the next balanced tool-pairing boundary so a compacted region never
|
||||
* splits a step's tool-call/result pair (an open tail step is never crossed —
|
||||
* compaction declines and retries once it closes).
|
||||
* - **Summarization** — `ctx.llm.stream()` assembled via `BlockAssembler`
|
||||
* (the single model-call surface; same path the loop uses) with a fixed
|
||||
* condense-the-history system prompt.
|
||||
* - **Surface mutation** — a single `user/message` replace node carries the
|
||||
* summary; `compact/*` events are log-only lock + provenance records.
|
||||
* - **Auto-compaction** — an `agent/request` waterfall listener delegates to
|
||||
* {@link BasicCompactService.compactIfNeeded} before EVERY model call (every
|
||||
* step, so a tool-heavy turn that grows the surface mid-turn still compacts);
|
||||
* it owns the sole token-pressure check.
|
||||
* - **Auto-compaction** — an `agent/pre-step` listener delegates to
|
||||
* {@link BasicCompactService.compactIfNeeded} before EVERY step (so a
|
||||
* tool-heavy turn that grows the surface mid-turn still compacts); it owns the
|
||||
* sole token-pressure check.
|
||||
*
|
||||
* A different backend (real tokenizer, template summarizer, turn-count
|
||||
* retention) either subclasses this and overrides the {@link
|
||||
@@ -33,7 +33,7 @@ import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
import { resolveConfig } from './types.ts'
|
||||
@@ -169,23 +169,26 @@ export class BasicCompactService extends CompactService {
|
||||
this.config = resolveConfig(config)
|
||||
|
||||
if (this.config.auto) {
|
||||
// Auto-compaction: delegate to compactIfNeeded before EVERY model call —
|
||||
// every step, not just the first. This is LOAD-BEARING for runaway-turn
|
||||
// survival: a tool-heavy ReAct turn appends an assistant/message and a
|
||||
// tool/result per step, so the surface (and the derived token count) grows
|
||||
// WITHIN a turn. The only moment to rescue a turn that alone approaches the
|
||||
// window is the next step's pre-request; gating to a turn's first step
|
||||
// would let a runaway turn overflow before the next turn's check. The
|
||||
// listener owns NO threshold logic — compactIfNeeded is the single place
|
||||
// that decides whether to compact, and its in-progress lock serializes
|
||||
// concurrent attempts.
|
||||
// Auto-compaction: delegate to compactIfNeeded before EVERY step. This is
|
||||
// LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends
|
||||
// an assistant/message and a tool/result per step, so the surface (and the
|
||||
// derived token count) grows WITHIN a turn. The only moment to rescue a
|
||||
// turn that alone approaches the window is the next step's pre-step
|
||||
// checkpoint; gating to a turn's first step would let a runaway turn
|
||||
// overflow before the next turn's check. The listener owns NO threshold
|
||||
// logic — compactIfNeeded is the single place that decides whether to
|
||||
// compact, and its in-progress lock serializes concurrent attempts.
|
||||
//
|
||||
// It runs on `agent/pre-request` (a parallel surface-mutation checkpoint),
|
||||
// NOT `agent/request`: compaction mutates the session surface, and the loop
|
||||
// derives the request `messages` AFTER this fires — so a single derive
|
||||
// already reflects the compaction, with no double-derive and no need to
|
||||
// rewrite an already-assembled `messages` array.
|
||||
ctx.on('agent/pre-request', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => {
|
||||
// It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired
|
||||
// AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction
|
||||
// mutates the session surface, and the loop derives the request `messages`
|
||||
// AFTER this fires — so a single derive already reflects the compaction,
|
||||
// with no double-derive and no need to rewrite an already-assembled
|
||||
// `messages` array. Firing pre-step (outside any open step) keeps the
|
||||
// log-only `compact/*` records and the replacement node cleanly outside a
|
||||
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
|
||||
// closes — never a half-open step.
|
||||
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => {
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent.session, system, model, signal)
|
||||
if (result) {
|
||||
@@ -321,17 +324,19 @@ export class BasicCompactService extends CompactService {
|
||||
* Retention is a UNIFORM tail→head walk over the whole surface — turn
|
||||
* boundaries play NO role. Walking node-by-node from the tail and summing
|
||||
* token estimates, once the retained total reaches `retainTokens` the cutoff
|
||||
* is rounded to a step-aligned boundary: if the walk stopped INSIDE a step,
|
||||
* it continues head-ward past that step's `step/start` so the whole step is
|
||||
* retained (never splitting a step's tool-calls from their results); if it
|
||||
* stopped on a free node (a node belonging to no step), that is already a
|
||||
* clean boundary. This always rounds toward retaining MORE (retained ≥
|
||||
* `retainTokens`) and is step-aligned by construction — no separate snap pass.
|
||||
* is rounded to a balanced tool-pairing boundary: if the cut before the
|
||||
* retained node is unbalanced (an unanswered tool-call sits before it — i.e.
|
||||
* it is mid-step), the walk continues head-ward until the cut is balanced so
|
||||
* the whole step is retained (never splitting a step's tool-calls from their
|
||||
* results); if it stopped on a free node (a node belonging to no step), that
|
||||
* cut is already balanced. This always rounds toward retaining MORE (retained
|
||||
* ≥ `retainTokens`) and is boundary-safe by construction — no separate snap
|
||||
* pass.
|
||||
*
|
||||
* The compacted range is always anchored at the surface HEAD (`nodes[0]`):
|
||||
* auto-compaction re-consolidates any prior head checkpoint into one fresh
|
||||
* checkpoint. Declines (`null`) when nothing is over threshold, when the whole
|
||||
* surface fits the retain budget, or when no step-aligned cutoff exists in the
|
||||
* surface fits the retain budget, or when no balanced cutoff exists in the
|
||||
* compactable range (its only content is an open tail step — retry once it
|
||||
* closes).
|
||||
*/
|
||||
@@ -371,24 +376,26 @@ export class BasicCompactService extends CompactService {
|
||||
// The whole surface fits the retain budget — nothing to compact.
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// Round the cutoff to a step boundary: if `keepFromIdx` sits INSIDE a step,
|
||||
// extend the retained side head-ward until the boundary is a step-aligned
|
||||
// start, so the compacted range ends on a clean step edge. A node that
|
||||
// belongs to no step is already a valid start. Decline if no step-aligned
|
||||
// start exists at or below `keepFromIdx` (the compactable range is only an
|
||||
// un-splittable open tail step — retry once it closes).
|
||||
// Round the cutoff to a tool-pairing boundary: if the cut before
|
||||
// `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before
|
||||
// it — i.e. it is mid-step), extend the retained side head-ward until the
|
||||
// cut is balanced, so the compacted range ends without splitting an
|
||||
// assistant↔result pair. A node that belongs to no step is already a
|
||||
// balanced (free) boundary. Decline if no balanced cut exists at or below
|
||||
// `keepFromIdx` (the compactable range is only an un-splittable open tail
|
||||
// step — retry once it closes).
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isStepAlignedStart(events, nodes[keepFromIdx]!.seq)) break
|
||||
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// The compacted range is [head … keepFromIdx - 1], anchored at the head.
|
||||
// The cutoff node `nodes[keepFromIdx - 1]` is necessarily a step-aligned END:
|
||||
// the retained start `nodes[keepFromIdx]` is a step-aligned START (a boundary
|
||||
// marker sits between them in the log), and that same boundary makes the node
|
||||
// before it a step-aligned end — so no separate end check is needed.
|
||||
// The cutoff node `nodes[keepFromIdx - 1]` is necessarily a balanced END:
|
||||
// the retained start `nodes[keepFromIdx]` opens on a balanced cut, and that
|
||||
// same cut is the cut AFTER `nodes[keepFromIdx - 1]` — so no separate end
|
||||
// check is needed.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const firstSeq = nodes[0]!.seq
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -420,19 +427,24 @@ export class BasicCompactService extends CompactService {
|
||||
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
|
||||
}
|
||||
|
||||
// The region must contain whole steps, never split a step's
|
||||
// assistant-message tool-calls from their tool/results (which would orphan
|
||||
// one side and produce a transcript every provider rejects). A boundary is
|
||||
// valid when it sits on a step edge or on a node that belongs to no step
|
||||
// (pre-step user message, inter-step steering, injection context); an `end`
|
||||
// inside an open (unclosed) tail step is also rejected — its tool-calls have
|
||||
// no results yet. See dsh-session's step-boundary predicates.
|
||||
// The region must never split a step's assistant-message tool-calls from
|
||||
// their tool/results (which would orphan one side and produce a transcript
|
||||
// every provider rejects). A region is safe iff BOTH its edges are balanced
|
||||
// cuts: the cut before `start`, and the cut after `end`. A node that belongs
|
||||
// to no step (pre-step user message, inter-step steering, injection context)
|
||||
// is a balanced (free) boundary; an `end` inside an open (unclosed) tail step
|
||||
// leaves the cut after it unbalanced (the open tool-call has no result yet),
|
||||
// so it is rejected. See dsh-session's tool-pairing balance check.
|
||||
const events = session.events
|
||||
if (!isStepAlignedStart(events, start)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not on a step boundary (would split a step's tool-call/result pair)`)
|
||||
if (!isToolPairingBalanced(nodes, events, start)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
if (!isStepAlignedEnd(events, end)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not on a step boundary (would split a step, or the step is still open)`)
|
||||
// The cut after `end` is named by `end`'s surface successor, or `null` when
|
||||
// `end` is the tail.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const afterEnd: number | null = nodes[endIdx]!.next
|
||||
if (!isToolPairingBalanced(nodes, events, afterEnd)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
if (this._isCompactionInProgress(session)) {
|
||||
@@ -441,10 +453,11 @@ export class BasicCompactService extends CompactService {
|
||||
|
||||
// Compaction's events (compact/* and the replacement user/message) must be
|
||||
// turn-enclosed: the session-log contract rejects any plugin event appended
|
||||
// outside an open turn. Auto-compaction satisfies this — it runs inside the
|
||||
// `agent/request` waterfall, strictly between a turn's start and end. A
|
||||
// manual call on a fully-closed session has no turn to enclose the events,
|
||||
// so reject rather than emit an un-enclosed run.
|
||||
// outside an open turn. Auto-compaction satisfies this — it runs on the
|
||||
// `agent/pre-step` seam, after `turn/start` and before `step/start`, so
|
||||
// strictly inside the open turn (but outside any step). A manual call on a
|
||||
// fully-closed session has no turn to enclose the events, so reject rather
|
||||
// than emit an un-enclosed run.
|
||||
const turn = this._openTurn(session)
|
||||
if (turn === null) {
|
||||
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
|
||||
|
||||
Reference in New Issue
Block a user