diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md
index a1ca8978a6..05d1ce6c2e 100644
--- a/docs/core-data-structures/compaction.md
+++ b/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.
diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md
index ba6f6c6ae6..29371a3abb 100644
--- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md
+++ b/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
diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts
index 17186055b1..a300aac69a 100644
--- a/examples/coding-agent/tests/compaction.e2e.ts
+++ b/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',
})
diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md
index 2b13666033..5d4f2c755e 100644
--- a/packages/compact/compact-basic/README.md
+++ b/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 `…` 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
diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts
index e474be7cd9..7d84b52511 100644
--- a/packages/compact/compact-basic/src/index.ts
+++ b/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 {
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.
diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts
index 13365b7ed1..8c4753c84f 100644
--- a/packages/compact/compact-basic/src/types.ts
+++ b/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].`)
+ }
+}
diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts
index fbbb8258c5..73fc6246e6 100644
--- a/packages/compact/compact-basic/tests/compact-basic.spec.ts
+++ b/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()
/** 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 {
+ 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
diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
index e12861a43a..dddb636b21 100644
--- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
+++ b/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 }
}