fix(compact): harden summarization convergence

Use maxTokens as the provider generation cap and remove the confusing stored-summary max config.

Strip reasoning blocks before storing compaction summaries, reject non-shrinking summaries, and retry bounded re-compaction when the surface remains over threshold.

Add config validation for numeric and type-shaped knobs plus unit and real-API e2e coverage for reasoning-capable summarization.
This commit is contained in:
Hypatia May
2026-06-29 16:56:44 +08:00
parent 1f35a4446d
commit 1808570933
8 changed files with 319 additions and 149 deletions

View File

@@ -52,4 +52,4 @@ interface CompactionResult {
`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, turn, step, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, lifecycle context, assembled `fullSystemPrompt`, and turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation.
Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, the approximate convergence invariant, and the crash/recoverable failure taxonomy.
Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, summary shrink validation, bounded re-compaction, and the crash/recoverable failure taxonomy.

View File

@@ -66,7 +66,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed*
### Approximate convergence invariant
`resolveConfig` **rejects** (throws at construction) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant bounds the two variable parts of post-compaction history — the bounded summary plus the retained recent tail — but it is intentionally approximate: checkpoint framing, per-message role overhead, system-prompt size, and the char/4 estimator's error can still leave a narrow accepted config near the threshold. The bound is **strict** (`>=` rejects, not `>`): the token-pressure gate declines only when the estimate is `< threshold`, so a post-compaction history sitting *exactly* at the threshold would re-trigger on the very next check — equality is a leak, not a safe boundary. `summarizationMaxTokens` stays an explicit *quality* knob (terse summaries); the invariant only forbids setting it so high it breaks the structural budget. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug. Per the pre-release reject-don't-migrate stance, a config that cannot satisfy the structural bound is a bug at the call site, not something to silently clamp.
`resolveConfig` validates numeric knobs but does NOT reject based on a pretend summary-length invariant. Convergence is dynamic: provider output caps can be spent on hidden or surfaced reasoning tokens, and the model may emit a summary of unpredictable size. `maxTokens` is only the provider-side generation cap for the summarization call; reasoning blocks are stripped before the checkpoint is stored. If a compacted surface is still over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times, but each committed summary must be smaller than the content it shadows. The sole residual is the single-unit-overflow case above (a backward-rounded oversized step can push the retained tail over budget) — which is exactly the out-of-scope concern, not a thrash bug.
### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary

View File

@@ -40,18 +40,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40))
}
// Tiny window so a couple of steps crosses the threshold. The convergence
// invariant requires summarizationMaxTokens + retainTokens to be strictly
// BELOW the threshold = floor(contextWindow * thresholdRatio) =
// floor(2400 * 0.5) = 1200; 600 + 500 = 1100 < 1200. The summary cap
// stays high enough for the live model to emit the required checkpoint
// sections; a truncated checkpoint fails closed and leaves no summary.
// Tiny window so a couple of steps crosses the threshold. The generation
// cap is deliberately larger than the final checkpoint because
// reasoning-capable APIs count reasoning tokens against the provider output
// budget even though those blocks are stripped before the checkpoint is
// stored.
ctx = await codingHarness(workdir, {
compact: {
contextWindow: 2400,
thresholdRatio: 0.5,
retainTokens: 500,
summarizationMaxTokens: 600,
maxTokens: 2048,
},
persistenceRoot: './.sessions',
})

View File

@@ -10,8 +10,8 @@ The abstract contract states only WHAT compaction does; this backend owns every
- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction. The bound is strict (`>=` rejects) because the token-pressure gate declines only when the estimate is `< threshold` — a post-compaction history sitting exactly at the threshold would re-trigger.
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; reasoning blocks from reasoning-capable APIs are stripped before the checkpoint is stored. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
- **Surface mutation** — `compactRegion()` appends the `compact/start``compact/summary``compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order, no-veto) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`).
@@ -27,7 +27,8 @@ The abstract contract states only WHAT compaction does; this backend owns every
| `thresholdRatio` | `0.8` | Compact when estimated usage exceeds this fraction of the window. |
| `retainTokens` | `20480` | Tokens of recent context to keep intact. |
| `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). |
| `summarizationMaxTokens` | `2048` | Max tokens for the summary response. |
| `maxTokens` | `8192` | Provider generation cap for the summarization call; may include reasoning tokens. |
| `compactionRetries` | `1` | Extra compaction attempts after the first if the compacted surface remains over threshold. |
| `auto` | `true` | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
## Usage

View File

@@ -290,7 +290,7 @@ export class BasicCompactService extends CompactService {
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
}],
system: SUMMARIZE_SYSTEM_PROMPT,
maxTokens: this.config.summarizationMaxTokens,
maxTokens: this.config.maxTokens,
}
// exactOptionalPropertyTypes: only set `signal` when present — assigning
// `undefined` to an optional `signal?: AbortSignal` is a type error.
@@ -306,7 +306,12 @@ export class BasicCompactService extends CompactService {
const error = finishError(assembler.finish)
if (error) throw error
return assembler.message().content
const summary = this._stripReasoning(assembler.message().content)
if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) {
throw new Error('summarization produced no non-reasoning summary content')
}
return summary
}
// ---- Core API (implements the abstract contract) ----
@@ -345,62 +350,28 @@ export class BasicCompactService extends CompactService {
signal: AbortSignal,
): Promise<CompactionResult | null> {
const session = agent.session
const messages = session.deriveMessages()
const totalTokens = this.estimateTokens(messages, fullSystemPrompt)
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
if (totalTokens < threshold) return null
let result: CompactionResult | null = null
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
if (totalTokens < threshold) return result
const nodes = session.surface.nodes
if (nodes.length === 0) return null
const range = this._compactableRange(session)
if (range === null) {
if (result === null) return null
break
}
const events = session.events
const retainBudget = this.config.retainTokens
// Walk tail→head summing per-node token estimates. `keepFromIdx` is the
// index of the OLDEST node we retain verbatim; everything strictly older
// (`[0, keepFromIdx - 1]`) is the compactable range.
let accumulated = 0
let keepFromIdx = nodes.length // nothing retained yet
for (let i = nodes.length - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = nodes[i]!
const event = events[node.seq]
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
if (event) accumulated += this.estimateEventTokens(event)
keepFromIdx = i
if (accumulated >= retainBudget) break
result = await this.compactRegion(session, range.start, range.end, agent, turn, step, signal)
}
// The whole surface fits the retain budget — nothing to compact.
if (keepFromIdx === 0) return null
const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
if (totalTokens < threshold) return result
// 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 (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 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
const cutoffSeq = nodes[keepFromIdx - 1]!.seq
return this.compactRegion(session, firstSeq, cutoffSeq, agent, turn, step, signal)
throw new Error(
`compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
+ `(${totalTokens} estimated tokens >= threshold ${threshold})`,
)
}
override async compactRegion(
@@ -482,7 +453,12 @@ export class BasicCompactService extends CompactService {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
shadowedTokenCount += this.estimateEventTokens(session.events[seq]!)
}
const summaryTokenCount = this.estimateContentTokens(summary)
if (summaryTokenCount >= shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${summaryTokenCount} estimated tokens >= ${shadowedTokenCount})`,
)
}
// --- Provenance record (log-only) ---
const summaryEvent = session.append('compact/summary', {
summary,
@@ -580,6 +556,72 @@ export class BasicCompactService extends CompactService {
return false
}
/** Resolve the next head-anchored compactable surface range, or `null`. */
private _compactableRange(session: Session): { start: number; end: number } | null {
const nodes = session.surface.nodes
if (nodes.length === 0) return null
const events = session.events
const retainBudget = this.config.retainTokens
// Walk tail→head summing per-node token estimates. `keepFromIdx` is the
// index of the OLDEST node we retain verbatim; everything strictly older
// (`[0, keepFromIdx - 1]`) is the compactable range.
let accumulated = 0
let keepFromIdx = nodes.length // nothing retained yet
for (let i = nodes.length - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = nodes[i]!
const event = events[node.seq]
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
if (event) accumulated += this.estimateEventTokens(event)
keepFromIdx = i
if (accumulated >= retainBudget) break
}
// The whole surface fits the retain budget — nothing to compact.
if (keepFromIdx === 0) return null
// 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 (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.
// 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
const cutoffSeq = nodes[keepFromIdx - 1]!.seq
return { start: firstSeq, end: cutoffSeq }
}
/** Remove reasoning blocks from model-produced summary content before storing it. */
private _stripReasoning(blocks: readonly ContentBlock[]): ContentBlock[] {
const stripped: ContentBlock[] = []
for (const block of blocks) {
switch (block.type) {
case 'reasoning':
break
case 'tool-result':
stripped.push({ ...block, content: this._stripReasoning(block.content) })
break
default:
stripped.push(block)
}
}
return stripped
}
/**
* The turn number of the currently OPEN turn — a `turn/start` not yet
* followed by its `turn/end` — or `null` if the session has no open turn.

View File

@@ -19,8 +19,10 @@ export interface BasicCompactConfig {
retainTokens?: number
/** Model to use for summarization (default '' — uses the agent's model). */
summarizationModel?: string
/** Maximum tokens for the summarization response (default 2048). */
summarizationMaxTokens?: number
/** Provider generation cap for the summarization call (default 8192). */
maxTokens?: number
/** Extra compaction attempts when the first compacted surface is still over threshold (default 1). */
compactionRetries?: number
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
auto?: boolean
}
@@ -34,40 +36,52 @@ export const DEFAULTS: ResolvedConfig = {
thresholdRatio: 0.8,
retainTokens: 20480,
summarizationModel: '',
summarizationMaxTokens: 2048,
maxTokens: 8192,
compactionRetries: 1,
auto: true,
}
/**
* Apply defaults to a partial config and enforce the approximate convergence
* invariant.
* Apply defaults to a partial config and reject nonsensical numeric knobs.
*
* `summarizationMaxTokens + retainTokens` must be strictly BELOW the compaction
* threshold (`contextWindow * thresholdRatio`). The invariant bounds the two
* variable pieces of post-compaction history — the summary and the retained
* recent tail — but it is intentionally approximate: checkpoint framing,
* per-message role overhead, system-prompt size, and the char/4 estimator's
* error can still leave a narrow accepted config near the threshold. The bound
* is strict (`>=` rejects) because `compactIfNeeded` declines only when the
* estimate is `< threshold`: a post-compaction history sitting EXACTLY at the
* threshold would re-trigger on the next check. Pre-release we reject rather
* than clamp: a config that cannot satisfy even this structural bound is a bug
* at the call site, not something to silently paper over.
*
* @throws if `summarizationMaxTokens + retainTokens >= contextWindow * thresholdRatio`.
* Convergence is not a static config invariant: provider generation caps can be
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
* of unpredictable size. The backend instead enforces convergence dynamically:
* each committed summary must be smaller than the content it shadows, and
* `compactIfNeeded` may re-compact up to `compactionRetries` extra times before
* throwing if the surface still exceeds the threshold.
*/
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
const resolved = { ...DEFAULTS, ...config }
const threshold = Math.floor(resolved.contextWindow * resolved.thresholdRatio)
const postCompactionFloor = resolved.summarizationMaxTokens + resolved.retainTokens
if (postCompactionFloor >= threshold) {
throw new Error(
`BasicCompactConfig: summarizationMaxTokens (${resolved.summarizationMaxTokens}) + `
+ `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} is not below the compaction `
+ `threshold contextWindow * thresholdRatio = ${threshold}; post-compaction history would `
+ 'stay at/over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens '
+ 'or raise contextWindow/thresholdRatio.',
)
assertPositiveInteger('contextWindow', resolved.contextWindow)
assertRatio('thresholdRatio', resolved.thresholdRatio)
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
assertPositiveInteger('maxTokens', resolved.maxTokens)
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
if (typeof resolved.summarizationModel !== 'string') {
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
}
if (typeof resolved.auto !== 'boolean') {
throw new Error('BasicCompactConfig: auto must be a boolean.')
}
return resolved
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer.`)
}
}
function assertNonNegativeInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer.`)
}
}
function assertRatio(name: string, value: number): void {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)
}
}

View File

@@ -17,14 +17,18 @@ const SIGNAL = new AbortController().signal
* predictable token estimate, for deterministic unit tests of the algorithm.
*/
class TestCompactService extends BasicCompactService {
private readonly summaryOutputs = new WeakSet<readonly ContentBlock[]>()
/** Track calls to summarize for test assertions. */
summarizeCalls: { text: string; model: string }[] = []
/** The fixed summary to return. */
mockSummary: ContentBlock[] = [{ type: 'text', text: 'Test summary of compacted content.' }]
/** Per-call summaries; when set, each summarize() call shifts one value. */
mockSummaryQueue: ContentBlock[][] = []
/** If set, summarize() throws this error. */
summarizeError: Error | null = null
override estimateContentTokens(blocks: readonly ContentBlock[]): number {
if (this.summaryOutputs.has(blocks)) return blocks.length * 2
// 10 tokens per block — predictable for retention/threshold math.
return blocks.length * 10
}
@@ -33,18 +37,15 @@ class TestCompactService extends BasicCompactService {
const model = this.config.summarizationModel || agent.options.model || ''
this.summarizeCalls.push({ text, model })
if (this.summarizeError) throw this.summarizeError
return this.mockSummary
const summary = this.mockSummaryQueue.shift() ?? this.mockSummary
this.summaryOutputs.add(summary)
return summary
}
}
/**
* Create a test service with a throwaway context (auto disabled — no model).
* A small `summarizationMaxTokens` baseline keeps the convergence invariant
* (`summarizationMaxTokens + retainTokens <= contextWindow * thresholdRatio`)
* satisfied for the tiny windows these tests use; a test may override it.
*/
/** Create a test service with a throwaway context (auto disabled — no model). */
function createTestService(config: BasicCompactConfig = {}): TestCompactService {
return new TestCompactService(new Context(), { auto: false, summarizationMaxTokens: 1, ...config })
return new TestCompactService(new Context(), { auto: false, ...config })
}
/**
@@ -182,7 +183,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
// region always ends on a step boundary, so no step's tool-call is split
// from its result. retainTokens=55 keeps the recent tail; the older steps
// compact intact.
const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 })
const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 })
const session = toolTurnSession(3)
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL)
@@ -520,7 +521,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
})
it('walks tail→head and retains nodes within token budget', async () => {
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 })
const svc = createTestService({ contextWindow: 350, thresholdRatio: 0.2, retainTokens: 15 })
const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL)
@@ -531,13 +532,12 @@ describe('BasicCompactService.compactIfNeeded', () => {
})
it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => {
// threshold = floor(470*0.1) = 47. The 4 surface nodes weigh 10 each (raw 40
// threshold = floor(480*0.1) = 48. The 4 surface nodes weigh 10 each (raw 40
// for the retention walk), but the derived estimate adds 4 role tokens per
// message → 56 ≥ 47, so the threshold check passes and the walk runs. The
// message → 56 ≥ 48, so the threshold check passes and the walk runs. The
// walk accumulates all 40 < retainTokens (45) without crossing the budget,
// so keepFromIdx reaches 0 and compaction declines. The invariant holds:
// summarizationMaxTokens (1) + retainTokens (45) = 46 < threshold 47.
const svc = createTestService({ contextWindow: 470, thresholdRatio: 0.1, retainTokens: 45 })
// so keepFromIdx reaches 0 and compaction declines.
const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 })
const session = multiTurnSession(2, 1)
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
})
@@ -553,7 +553,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
// verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded
// returned null and shadowedSeqs would be empty — the runaway turn could
// never compact and the next model call would overflow the window.
const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 })
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
const s = new Session(SessionId('runaway'))
// ONE open turn with 5 closed steps; each step is [asst(tool-call), result].
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -598,7 +598,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
// never stranded. retainTokens=25 leaves a couple of retained nodes after
// the first compaction (so the surface is [summary, …retained], not just
// [summary]).
const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 })
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet)
const first = await compactIfNeeded(svc, s, '', 'm', SIGNAL)
@@ -623,6 +623,45 @@ describe('BasicCompactService.compactIfNeeded', () => {
const turn5UserSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'turn 5 work'))!.seq
expect(second!.shadowedSeqs).not.toContain(turn5UserSeq)
})
it('re-compacts smaller summaries until the post-compaction surface drops below threshold', async () => {
const svc = createTestService({
contextWindow: 100,
thresholdRatio: 0.5,
retainTokens: 10,
compactionRetries: 2,
})
svc.mockSummaryQueue = [
Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })),
[{ type: 'text', text: 'second' }],
]
const session = multiTurnSession(4, 1)
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL)
expect(result).not.toBeNull()
expect(svc.summarizeCalls).toHaveLength(2)
expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(2)
expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(50)
})
it('throws after the configured re-compaction attempts still leave the surface above threshold', async () => {
const svc = createTestService({
contextWindow: 100,
thresholdRatio: 0.5,
retainTokens: 10,
compactionRetries: 1,
})
svc.mockSummaryQueue = [
Array.from({ length: 4 }, (_, index) => ({ type: 'text', text: `first ${index}` })),
Array.from({ length: 3 }, (_, index) => ({ type: 'text', text: `second ${index}` })),
]
const session = multiTurnSession(4, 1)
await expect(compactIfNeeded(svc, session, '', 'm', SIGNAL))
.rejects.toThrow(/still above threshold after 2 compaction attempts/)
expect(svc.summarizeCalls).toHaveLength(2)
})
})
describe('BasicCompactService replay equivalence', () => {
@@ -753,31 +792,31 @@ describe('BasicCompactService HMR safety', () => {
})
})
describe('BasicCompactService convergence invariant (config)', () => {
it('throws when summarizationMaxTokens + retainTokens exceeds the threshold', () => {
// threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 is not below 500 → reject.
expect(() => new BasicCompactService(new Context(), {
auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 200,
})).toThrow(/not below the compaction threshold/)
describe('BasicCompactService config validation', () => {
it('rejects invalid numeric config values', () => {
expect(() => new BasicCompactService(new Context(), { auto: false, contextWindow: 0 })).toThrow(/contextWindow .* positive integer/)
expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 0 })).toThrow(/thresholdRatio .* \(0, 1\]/)
expect(() => new BasicCompactService(new Context(), { auto: false, thresholdRatio: 1.1 })).toThrow(/thresholdRatio .* \(0, 1\]/)
expect(() => new BasicCompactService(new Context(), { auto: false, retainTokens: -1 })).toThrow(/retainTokens .* non-negative integer/)
expect(() => new BasicCompactService(new Context(), { auto: false, maxTokens: 0 })).toThrow(/maxTokens .* positive integer/)
expect(() => new BasicCompactService(new Context(), { auto: false, compactionRetries: -1 }))
.toThrow(/compactionRetries .* non-negative integer/)
expect(() => new BasicCompactService(new Context(), { auto: false, summarizationModel: 1 } as unknown as BasicCompactConfig))
.toThrow(/summarizationModel must be a string/)
expect(() => new BasicCompactService(new Context(), { auto: 'no' } as unknown as BasicCompactConfig))
.toThrow(/auto must be a boolean/)
})
it('rejects the boundary case (sum equals the threshold — would re-trigger)', () => {
// threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 is NOT below 500, so
// post-compaction history would sit exactly at threshold and re-compact.
it('accepts a large retain budget because convergence is enforced dynamically', () => {
expect(() => new BasicCompactService(new Context(), {
auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 100,
})).toThrow(/not below the compaction threshold/)
})
it('accepts the case just below the threshold', () => {
// threshold = floor(1000 * 0.5) = 500; 99 + 400 = 499 < 500 → allowed.
expect(() => new BasicCompactService(new Context(), {
auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 99,
auto: false,
contextWindow: 1000,
thresholdRatio: 0.5,
retainTokens: 900,
})).not.toThrow()
})
it('the default config satisfies the invariant', () => {
// 2048 + 20480 = 22528 ≤ floor(128000 * 0.8) = 102400.
it('the default config is valid', () => {
expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow()
})
})
@@ -797,6 +836,41 @@ class ScriptedAdapter extends LlmAdapter {
}
}
/** An adapter that emits arbitrary content blocks, preserving reasoning/text shape. */
class BlocksAdapter extends LlmAdapter {
lastOptions: GenerateOptions | null = null
constructor(private blocks: readonly ContentBlock[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.lastOptions = options
for (const [index, block] of this.blocks.entries()) {
yield { type: 'block-start', index, blockType: block.type }
switch (block.type) {
case 'text':
yield { type: 'text-delta', index, text: block.text }
break
case 'reasoning':
yield { type: 'reasoning-delta', index, text: block.text }
break
default:
yield { type: 'block-end', index, block }
}
}
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
/** Wire a real LlmService + arbitrary-block adapter into a context. */
async function ctxWithBlocks(blocks: readonly ContentBlock[], model = 'test-model'): Promise<{ ctx: Context; adapter: BlocksAdapter }> {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new BlocksAdapter(blocks)
ctx.llm.registerAdapter([model], adapter)
return { ctx, adapter }
}
/** Wire a real LlmService + scripted adapter into a context. */
async function ctxWithModel(summaryText: string, model = 'test-model'): Promise<{ ctx: Context; adapter: ScriptedAdapter }> {
const ctx = new Context()
@@ -858,7 +932,7 @@ function summarize(svc: BasicCompactService, text: string, model: string) {
describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
it('summarizes via the registered adapter and returns its content', async () => {
const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT')
const svc = new BasicCompactService(ctx, { auto: false, summarizationMaxTokens: 512 })
const svc = new BasicCompactService(ctx, { auto: false, maxTokens: 512 })
const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model')
expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }])
@@ -869,6 +943,37 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
expect(adapter.lastOptions!.messages[0]!.content[0]).toMatchObject({ type: 'text' })
})
it('uses maxTokens as the summarization provider cap', async () => {
const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT')
const svc = new BasicCompactService(ctx, {
auto: false,
maxTokens: 50,
})
await summarize(svc, 'User: hi', 'test-model')
expect(adapter.lastOptions!.maxTokens).toBe(50)
})
it('strips reasoning blocks from the stored summary', async () => {
const { ctx } = await ctxWithBlocks([
{ type: 'reasoning', text: 'private chain of thought' },
{ type: 'text', text: 'PUBLIC SUMMARY' },
])
const svc = new BasicCompactService(ctx, { auto: false })
const summary = await summarize(svc, 'User: hi', 'test-model')
expect(summary).toEqual([{ type: 'text', text: 'PUBLIC SUMMARY' }])
})
it('throws when stripping reasoning leaves no summary text', async () => {
const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }])
const svc = new BasicCompactService(ctx, { auto: false })
await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no non-reasoning summary content/)
})
it('throws when no model is provided', async () => {
const { ctx } = await ctxWithModel('x')
const svc = new BasicCompactService(ctx, { auto: false })
@@ -930,6 +1035,17 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
// The raw summary is wrapped in the checkpoint framing on the surface.
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' })
})
it('rejects a summary that is not smaller than the shadowed content', async () => {
const svc = createTestService({ auto: false })
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
svc.mockSummary = Array.from({ length: 20 }, (_, index) => ({ type: 'text', text: `large ${index}` }))
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
.rejects.toThrow(/summary is not smaller than the shadowed content/)
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
})
})
describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => {
@@ -940,7 +1056,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
it('compacts (mutating the surface) when over threshold', async () => {
const { ctx } = await ctxWithModel('SUMMARY')
void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 })
void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })
const session = multiTurnSession(5, 1) // 10 surface nodes
const agent = stubAgent(session, 'test-model')
const before = session.surface.nodes.length
@@ -956,7 +1072,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => {
const { ctx } = await ctxWithModel('SUMMARY')
void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10, summarizationMaxTokens: 30 })
void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })
const session = multiTurnSession(3, 1) // over the 0.5 threshold
const agent = stubAgent(session, 'test-model')
@@ -981,7 +1097,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
// surface is untouched (the loop derives the full history).
const ctx = new Context()
await ctx.plugin(LlmService)
void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 1 })
void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 })
const session = multiTurnSession(3, 1)
const agent = stubAgent(session, 'missing-model')
const before = session.surface.nodes.length
@@ -994,7 +1110,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
it('does not register the listener when auto is false', async () => {
const { ctx } = await ctxWithModel('SUMMARY')
void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 1 })
void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 })
const session = multiTurnSession(3, 1)
const agent = stubAgent(session, 'test-model')
@@ -1008,7 +1124,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
options.model = 'routed-model'
return next()
})
void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 })
void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })
const session = multiTurnSession(5, 1)
const agent = stubAgent(session)
@@ -1025,7 +1141,6 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
contextWindow: 200,
thresholdRatio: 0.5,
retainTokens: 20,
summarizationMaxTokens: 50,
})
const session = multiTurnSession(5, 1)
const agent = stubAgent(session, 'test-model')
@@ -1139,14 +1254,16 @@ describe('BasicCompactService edge cases', () => {
expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0)
})
it('compacts once without re-checking a post-compaction threshold', async () => {
it('auto-compaction reports bounded retry exhaustion after committing a smaller summary', async () => {
const { ctx } = await ctxWithModel('SUMMARY')
// Even with a window so tiny the post-compaction history still exceeds the
// threshold, the agnostic listener does NOT re-gate or warn — it compacts
// once (the single check lives in compactIfNeeded) and proceeds.
const warnings: string[] = []
ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn
void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 5 })
void new BasicCompactService(ctx, {
contextWindow: 300,
thresholdRatio: 0.1,
retainTokens: 5,
compactionRetries: 0,
})
const session = multiTurnSession(4, 1)
const agent = stubAgent(session, 'test-model')
@@ -1154,8 +1271,7 @@ describe('BasicCompactService edge cases', () => {
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
// The surface was mutated; the head message is the framed summary checkpoint.
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
// No cascade warning is emitted.
expect(warnings.length).toBe(0)
expect(warnings.some(w => w.includes('still above threshold after 1 compaction attempts'))).toBe(true)
})
it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => {
@@ -1225,7 +1341,7 @@ describe('BasicCompactService edge cases', () => {
const { ctx } = await ctxWithModel('SUMMARY')
const warnings: string[] = []
ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn
const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 10 })
const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 })
svc.summarizeError = 'boom' as unknown as Error
const session = multiTurnSession(3, 1)
const agent = stubAgent(session, 'test-model')
@@ -1243,7 +1359,7 @@ describe('BasicCompactService edge cases', () => {
// A large system prompt pushes the listener's estimate over threshold, but
// retainTokens is huge so compactIfNeeded walks everything and returns null.
// threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200.
const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150, summarizationMaxTokens: 5 })
const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150 })
const session = multiTurnSession(2, 1)
const agent = stubAgent(session, 'test-model')
const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200

View File

@@ -91,14 +91,12 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
},
}))
// Tiny window so a couple of tool steps cross the threshold and compaction
// fires within the runaway turn. Convergence invariant holds:
// summarizationMaxTokens(1) + retainTokens(20) = 21 <= floor(60*0.5) = 30.
// fires within the runaway turn.
const compact = new ReproCompactService(ctx, {
auto: true,
contextWindow: 60,
contextWindow: 64,
thresholdRatio: 0.5,
retainTokens: 20,
summarizationMaxTokens: 1,
})
return { ctx, compact }
}