瀏覽代碼

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.
Hypatia May 3 月之前
父節點
當前提交
1808570933

+ 1 - 1
docs/core-data-structures/compaction.md

@@ -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.

+ 1 - 1
docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md

@@ -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
 

+ 6 - 7
examples/coding-agent/tests/compaction.e2e.ts

@@ -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',
     })

+ 4 - 3
packages/compact/compact-basic/README.md

@@ -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

+ 96 - 54
packages/compact/compact-basic/src/index.ts

@@ -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
-
-    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
+    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 range = this._compactableRange(session)
+      if (range === null) {
+        if (result === null) return null
+        break
+      }
 
-    // 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
+      result = await this.compactRegion(session, range.start, range.end, agent, turn, step, signal)
     }
-    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
+    const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
+    if (totalTokens < threshold) return result
 
-    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.

+ 42 - 28
packages/compact/compact-basic/src/types.ts

@@ -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].`)
+  }
+}

+ 170 - 54
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -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/)
-  })
-
-  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.
+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('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

+ 2 - 4
packages/compact/compact-basic/tests/compact-loop-repro.spec.ts

@@ -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 }
 }