Browse Source

fix(compact): make config knobs explicit and flag two review smells

Address @tianyicui's minor-revision review on PR #110:

- Make every BasicCompactConfig knob required except `auto` (defaults
  true): there is no data yet to justify default thresholds/budgets, so
  a consumer states each value explicitly. Drop the DEFAULTS export and
  the constructor's `= {}` default; example cordis.yml, the compaction
  e2e, the README, and every test construction site now pass a complete
  config (tests route through a `cfg()` helper).
- Add a TODO on estimateContentTokens: char/4 is coarse; replace with a
  real tokenizer or post-response usage feedback in a follow-up.
- Add a TODO on the agent/pre-step `fullSystemPrompt` param flagging it
  as a smell on a generic per-step seam (compaction is its sole
  consumer); a `//` line comment so it stays out of the generated catalog.
Tianyi Cui 3 months ago
parent
commit
5252477bc9

+ 7 - 7
docs/cordis-catalog/events-and-services.md

@@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:249`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts)
 
 #### `agent/pre-step` — serial
 
@@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts)
 
 #### `agent/queued` — emit
 
@@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo
 
 Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts)
 
 #### `agent/status` — emit
 
@@ -111,7 +111,7 @@ Steering content was injected into a running turn.
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-end` — emit
 
@@ -135,7 +135,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-start` — emit
 
@@ -159,7 +159,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed).
 
 Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-continuation` — waterfall
 
@@ -171,7 +171,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:231`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-end` — emit
 

+ 4 - 0
examples/coding-agent/cordis.yml

@@ -81,7 +81,11 @@
   name: '@deepseek-ai/dsh-compact-basic'
   config:
     contextWindow: 128000
+    thresholdRatio: 0.8
     retainTokens: 20480
+    summarizationModel: ''
+    maxTokens: 8192
+    compactionRetries: 1
 
 # The subagent seam + BOTH in-process backends + two model-facing tools, as leaf
 # entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh

+ 2 - 0
examples/coding-agent/tests/compaction.e2e.ts

@@ -50,7 +50,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
         contextWindow: 2400,
         thresholdRatio: 0.5,
         retainTokens: 500,
+        summarizationModel: '',
         maxTokens: 2048,
+        compactionRetries: 1,
       },
       persistenceRoot: './.sessions',
     })

+ 18 - 9
packages/compact/compact-basic/README.md

@@ -21,15 +21,17 @@ The abstract contract states only WHAT compaction does; this backend owns every
 
 ## Config (`BasicCompactConfig`)
 
-| Key | Default | Meaning |
+Every knob is **required** except `auto` — there is no concrete data yet to justify default thresholds/budgets, so a consumer states each value explicitly rather than inherit a guessed default. `auto` alone defaults to `true`.
+
+| Key | Required | Meaning |
 |---|---|---|
-| `contextWindow` | `128000` | Context window size in tokens. |
-| `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). |
-| `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. |
+| `contextWindow` | yes | Context window size in tokens. |
+| `thresholdRatio` | yes | Compact when estimated usage exceeds this fraction of the window. |
+| `retainTokens` | yes | Tokens of recent context to keep intact. |
+| `summarizationModel` | yes | Model for summarization (`''` → use the agent's model). |
+| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
+| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
+| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
 
 ## Usage
 
@@ -41,7 +43,14 @@ export const name = 'compact-basic'
 export const inject = ['llm']
 
 export function apply(ctx: Context): void {
-  ctx.plugin(BasicCompactService, { contextWindow: 128000, retainTokens: 20480 })
+  ctx.plugin(BasicCompactService, {
+    contextWindow: 128000,
+    thresholdRatio: 0.8,
+    retainTokens: 20480,
+    summarizationModel: '',
+    maxTokens: 8192,
+    compactionRetries: 1,
+  })
 }
 ```
 

+ 6 - 3
packages/compact/compact-basic/src/index.ts

@@ -39,7 +39,7 @@ import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
 import { resolveConfig } from './types.ts'
 
 export type { BasicCompactConfig, ResolvedConfig } from './types.ts'
-export { DEFAULTS, resolveConfig } from './types.ts'
+export { resolveConfig } from './types.ts'
 
 /** Per-block structural overhead for JSON framing / type tag. */
 const BLOCK_OVERHEAD = 4
@@ -155,10 +155,10 @@ function finishError(finish: FinishReason): Error | undefined {
 export class BasicCompactService extends CompactService {
   static inject = ['llm']
 
-  /** Resolved configuration (defaults applied). */
+  /** Resolved configuration (`auto` defaulted). */
   readonly config: ResolvedConfig
 
-  constructor(ctx: Context, config: BasicCompactConfig = {}) {
+  constructor(ctx: Context, config: BasicCompactConfig) {
     super(ctx)
     this.config = resolveConfig(config)
 
@@ -207,6 +207,9 @@ export class BasicCompactService extends CompactService {
 
   // ---- Token estimation (overridable hooks) ----
 
+  // TODO: char/4 is a coarse heuristic. Replace with an exact count — a real
+  // tokenizer, or the provider's post-response `usage` (input tokens) fed back
+  // as a correction — so threshold decisions match the model's actual budget.
   /**
    * Estimate the token count of content blocks — char/4 with per-block
    * overhead. Override in a subclass to plug in a real tokenizer.

+ 21 - 27
packages/compact/compact-basic/src/types.ts

@@ -9,40 +9,34 @@
  * @module @deepseek-ai/dsh-compact-basic/types
  */
 
-/** Backend configuration — all optional with sensible defaults. */
+/**
+ * Backend configuration. Every knob is REQUIRED except `auto`: there is no
+ * concrete data yet to justify default thresholds/budgets, so a consumer must
+ * state each value explicitly rather than inherit a guessed default. `auto`
+ * alone defaults to `true` (auto-compaction is the intended posture).
+ */
 export interface BasicCompactConfig {
-  /** Context window size in tokens (default 128000). */
-  contextWindow?: number
-  /** Compact when estimated token usage exceeds this fraction of context window (default 0.8). */
-  thresholdRatio?: number
-  /** Number of tokens of recent context to retain during compaction (default 20480). */
-  retainTokens?: number
-  /** Model to use for summarization (default '' — uses the agent's model). */
-  summarizationModel?: string
-  /** 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
+  /** Context window size in tokens. */
+  contextWindow: number
+  /** Compact when estimated token usage exceeds this fraction of context window. */
+  thresholdRatio: number
+  /** Number of tokens of recent context to retain during compaction. */
+  retainTokens: number
+  /** Model to use for summarization (`''` — uses the agent's model). */
+  summarizationModel: string
+  /** Provider generation cap for the summarization call. */
+  maxTokens: number
+  /** Extra compaction attempts when the first compacted surface is still over threshold. */
+  compactionRetries: number
   /** Enable automatic compaction on the `agent/pre-step` seam (default true). */
   auto?: boolean
 }
 
-/** Resolved config with all defaults applied. */
+/** Resolved config with `auto` defaulted. */
 export type ResolvedConfig = Required<BasicCompactConfig>
 
-/** Default configuration values. */
-export const DEFAULTS: ResolvedConfig = {
-  contextWindow: 128000,
-  thresholdRatio: 0.8,
-  retainTokens: 20480,
-  summarizationModel: '',
-  maxTokens: 8192,
-  compactionRetries: 1,
-  auto: true,
-}
-
 /**
- * Apply defaults to a partial config and reject nonsensical numeric knobs.
+ * Default `auto` when unset and reject nonsensical numeric knobs.
  *
  * 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
@@ -52,7 +46,7 @@ export const DEFAULTS: ResolvedConfig = {
  * throwing if the surface still exceeds the threshold.
  */
 export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
-  const resolved = { ...DEFAULTS, ...config }
+  const resolved: ResolvedConfig = { auto: true, ...config }
 
   assertPositiveInteger('contextWindow', resolved.contextWindow)
   assertRatio('thresholdRatio', resolved.thresholdRatio)

+ 73 - 51
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -12,6 +12,25 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
 /** A never-aborted signal for the required `compactIfNeeded`/listener arg. */
 const SIGNAL = new AbortController().signal
 
+/**
+ * Baseline config with every required knob set. `BasicCompactConfig` has no
+ * defaults for the numeric/model knobs (only `auto` defaults), so each test
+ * builds a complete config via `cfg()` and overrides only the knob under test.
+ */
+const TEST_CONFIG: BasicCompactConfig = {
+  contextWindow: 128000,
+  thresholdRatio: 0.8,
+  retainTokens: 20480,
+  summarizationModel: '',
+  maxTokens: 8192,
+  compactionRetries: 1,
+}
+
+/** A complete config with `overrides` applied over the baseline. */
+function cfg(overrides: Partial<BasicCompactConfig> = {}): BasicCompactConfig {
+  return { ...TEST_CONFIG, ...overrides }
+}
+
 /** Long enough that the real checkpoint preamble is smaller than two fixture messages. */
 const LONG_FIXTURE_TEXT = ' Detailed fixture context that makes framed checkpoint compaction genuinely shrinking.'.repeat(20)
 
@@ -59,8 +78,8 @@ function isFramedCheckpoint(blocks: readonly ContentBlock[]): boolean {
 }
 
 /** Create a test service with a throwaway context (auto disabled — no model). */
-function createTestService(config: BasicCompactConfig = {}): TestCompactService {
-  return new TestCompactService(new Context(), { auto: false, ...config })
+function createTestService(overrides: Partial<BasicCompactConfig> = {}): TestCompactService {
+  return new TestCompactService(new Context(), cfg({ auto: false, ...overrides }))
 }
 
 /**
@@ -761,7 +780,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
 
 describe('BasicCompactService token estimation (char/4 heuristic)', () => {
   it('estimates text blocks with char/4 + overhead', () => {
-    const svc = new BasicCompactService(new Context(), { auto: false })
+    const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
     // 'this is a somewhat longer text block' = 36 → ceil(36/4)+4 = 13; 'short' = 5 → 2+4 = 6
     const blocks: ContentBlock[] = [
       { type: 'text', text: 'this is a somewhat longer text block' },
@@ -771,13 +790,13 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
   })
 
   it('estimates reasoning blocks same as text', () => {
-    const svc = new BasicCompactService(new Context(), { auto: false })
+    const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
     // 'thinking about this...' = 22 → ceil(22/4)+4 = 10
     expect(svc.estimateContentTokens([{ type: 'reasoning', text: 'thinking about this...' }])).toBe(10)
   })
 
   it('estimates tool-call blocks from name + arguments', () => {
-    const svc = new BasicCompactService(new Context(), { auto: false })
+    const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
     // 'bash' = 4 → 1; '{"command":"ls"}' = 16 → 4; + 4 overhead = 9
     expect(svc.estimateContentTokens([
       { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' },
@@ -785,7 +804,7 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
   })
 
   it('estimates tool-result blocks recursively', () => {
-    const svc = new BasicCompactService(new Context(), { auto: false })
+    const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
     // inner text 5 → 2+4 = 6; outer 6 + 4 overhead = 10
     expect(svc.estimateContentTokens([
       { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'hello' }], isError: false },
@@ -793,12 +812,12 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
   })
 
   it('estimates image blocks at fixed 85 tokens', () => {
-    const svc = new BasicCompactService(new Context(), { auto: false })
+    const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
     expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85)
   })
 
   it('returns 0 for empty content blocks', () => {
-    const svc = new BasicCompactService(new Context(), { auto: false })
+    const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
     expect(svc.estimateContentTokens([])).toBe(0)
   })
 })
@@ -806,7 +825,7 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
 describe('BasicCompactService HMR safety', () => {
   it('registers as ctx.compact', () => {
     const ctx = new Context()
-    void new BasicCompactService(ctx, { auto: false })
+    void new BasicCompactService(ctx, cfg({ auto: false }))
     expect(ctx.compact).toBeDefined()
     expect(ctx.compact).toBeInstanceOf(BasicCompactService)
   })
@@ -819,7 +838,7 @@ describe('BasicCompactService HMR safety', () => {
     // under the "llm inject (real plugin-load path)" suite.)
     const ctx = new Context()
     await ctx.plugin(LlmService)
-    const fiber = await ctx.plugin(BasicCompactService, { auto: false })
+    const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false }))
     expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
 
     await fiber.dispose()
@@ -829,30 +848,33 @@ describe('BasicCompactService HMR safety', () => {
 
 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 }))
+    expect(() => new BasicCompactService(new Context(), cfg({ auto: false, contextWindow: 0 })))
+      .toThrow(/contextWindow .* positive integer/)
+    expect(() => new BasicCompactService(new Context(), cfg({ auto: false, thresholdRatio: 0 }))).toThrow(/thresholdRatio .* \(0, 1\]/)
+    expect(() => new BasicCompactService(new Context(), cfg({ auto: false, thresholdRatio: 1.1 }))).toThrow(/thresholdRatio .* \(0, 1\]/)
+    expect(() => new BasicCompactService(new Context(), cfg({ auto: false, retainTokens: -1 })))
+      .toThrow(/retainTokens .* non-negative integer/)
+    expect(() => new BasicCompactService(new Context(), cfg({ auto: false, maxTokens: 0 }))).toThrow(/maxTokens .* positive integer/)
+    expect(() => new BasicCompactService(new Context(), cfg({ 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))
+    expect(() => new BasicCompactService(
+      new Context(), cfg({ auto: false, summarizationModel: 1 } as unknown as Partial<BasicCompactConfig>),
+    )).toThrow(/summarizationModel must be a string/)
+    expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial<BasicCompactConfig>)))
       .toThrow(/auto must be a boolean/)
   })
 
   it('accepts a large retain budget because convergence is enforced dynamically', () => {
-    expect(() => new BasicCompactService(new Context(), {
+    expect(() => new BasicCompactService(new Context(), cfg({
       auto: false,
       contextWindow: 1000,
       thresholdRatio: 0.5,
       retainTokens: 900,
-    })).not.toThrow()
+    }))).not.toThrow()
   })
 
   it('the default config is valid', () => {
-    expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow()
+    expect(() => new BasicCompactService(new Context(), cfg({ auto: false }))).not.toThrow()
   })
 })
 
@@ -967,7 +989,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, maxTokens: 512 })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false, maxTokens: 512 }))
 
     const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model')
     expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }])
@@ -981,10 +1003,10 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
 
   it('uses maxTokens as the summarization provider cap', async () => {
     const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT')
-    const svc = new BasicCompactService(ctx, {
+    const svc = new BasicCompactService(ctx, cfg({
       auto: false,
       maxTokens: 50,
-    })
+    }))
 
     await summarize(svc, 'User: hi', 'test-model')
 
@@ -999,7 +1021,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
       // synthesized user/message summary as an orphaned call.
       { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
     ])
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
 
     const summary = await summarize(svc, 'User: hi', 'test-model')
 
@@ -1008,26 +1030,26 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
 
   it('throws when no text block remains after filtering', async () => {
     const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }])
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
 
     await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no text summary content/)
   })
 
   it('throws when no model is provided', async () => {
     const { ctx } = await ctxWithModel('x')
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
     await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/)
   })
 
   it('rethrows when the stream ends with a finish-error chunk', async () => {
     const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' })
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
     await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' })
   })
 
   it('rethrows a finish-error chunk without a code (code stays undefined)', async () => {
     const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' })
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
     const error = await summarize(svc, 'text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string })
     expect(error?.message).toBe('opaque failure')
     expect(error?.code).toBeUndefined()
@@ -1035,19 +1057,19 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
 
   it('rethrows when the stream ends with a finish-aborted chunk', async () => {
     const ctx = await ctxWithFinish({ kind: 'aborted' })
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
     await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' })
   })
 
   it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => {
     const ctx = await ctxWithFinish({ kind: 'max-tokens' })
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
     await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' })
   })
 
   it('compactRegion leaves the surface intact when summarization hits max-tokens', async () => {
     const ctx = await ctxWithFinish({ kind: 'max-tokens' })
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
     const session = multiTurnSession(2, 1)
     const before = [...session.surface.nodes]
     const nodes = session.surface.nodes
@@ -1065,7 +1087,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
 
   it('compactRegion uses the real summarizer end-to-end', async () => {
     const { ctx } = await ctxWithModel('CONDENSED')
-    const svc = new BasicCompactService(ctx, { auto: false })
+    const svc = new BasicCompactService(ctx, cfg({ auto: false }))
     const session = multiTurnSession(2, 1)
     const nodes = session.surface.nodes
 
@@ -1115,7 +1137,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 })
+    void new BasicCompactService(ctx, cfg({ 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
@@ -1133,12 +1155,12 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
     const ctx = new Context()
     const infos: string[] = []
     ctx.logger.info = ((msg: string) => void infos.push(msg)) as typeof ctx.logger.info
-    void new TestCompactService(ctx, {
+    void new TestCompactService(ctx, cfg({
       contextWindow: 100,
       thresholdRatio: 0.7,
       retainTokens: 10,
       compactionRetries: 0,
-    })
+    }))
     const session = multiTurnSession(3, 1)
     const agent = stubAgent(session, 'test-model')
 
@@ -1151,7 +1173,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 })
+    void new BasicCompactService(ctx, cfg({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 }))
     const session = multiTurnSession(3, 1) // over the 0.5 threshold
     const agent = stubAgent(session, 'test-model')
 
@@ -1163,7 +1185,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
 
   it('does nothing when under threshold', async () => {
     const { ctx } = await ctxWithModel('SUMMARY')
-    void new BasicCompactService(ctx, { contextWindow: 128000, thresholdRatio: 0.8 })
+    void new BasicCompactService(ctx, cfg({ contextWindow: 128000, thresholdRatio: 0.8 }))
     const session = multiTurnSession(1, 1)
     const agent = stubAgent(session, 'test-model')
 
@@ -1176,7 +1198,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 })
+    void new BasicCompactService(ctx, cfg({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10 }))
     const session = multiTurnSession(3, 1)
     const agent = stubAgent(session, 'missing-model')
     const before = session.surface.nodes.length
@@ -1189,7 +1211,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 })
+    void new BasicCompactService(ctx, cfg({ auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 }))
     const session = multiTurnSession(3, 1)
     const agent = stubAgent(session, 'test-model')
 
@@ -1203,7 +1225,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 })
+    void new BasicCompactService(ctx, cfg({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 }))
     const session = multiTurnSession(5, 1)
     const agent = stubAgent(session)
 
@@ -1216,11 +1238,11 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
 
   it('removes the auto pre-step listener when the plugin fiber is disposed', async () => {
     const { ctx } = await ctxWithModel('SUMMARY')
-    const fiber = await ctx.plugin(BasicCompactService, {
+    const fiber = await ctx.plugin(BasicCompactService, cfg({
       contextWindow: 200,
       thresholdRatio: 0.5,
       retainTokens: 20,
-    })
+    }))
     const session = multiTurnSession(5, 1)
     const agent = stubAgent(session, 'test-model')
 
@@ -1327,7 +1349,7 @@ describe('BasicCompactService edge cases', () => {
   })
 
   it('estimates unknown block types via JSON length (default branch)', () => {
-    const svc = new BasicCompactService(new Context(), { auto: false })
+    const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
     // A block whose type is none of the known kinds — exercises the default arm.
     const unknown = { type: 'custom-widget', payload: 'some data' } as unknown as ContentBlock
     expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0)
@@ -1337,12 +1359,12 @@ 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
-    void new BasicCompactService(ctx, {
+    void new BasicCompactService(ctx, cfg({
       contextWindow: 300,
       thresholdRatio: 0.1,
       retainTokens: 5,
       compactionRetries: 0,
-    })
+    }))
     const session = multiTurnSession(4, 1)
     const agent = stubAgent(session, 'test-model')
 
@@ -1420,7 +1442,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 })
+    const svc = new TestCompactService(ctx, cfg({ 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')
@@ -1438,7 +1460,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 })
+    const svc = new TestCompactService(ctx, cfg({ 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
@@ -1606,7 +1628,7 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => {
     ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED'))
     // Mount the service through its real plugin fiber (NOT new …(rootCtx)), so
     // the sibling-fiber ctx.llm resolution actually exercises the inject.
-    const fiber = await ctx.plugin(BasicCompactService, { auto: false })
+    const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false }))
 
     const svc = ctx.compact as BasicCompactService
     const session = multiTurnSession(2, 1)
@@ -1635,7 +1657,7 @@ describe('BasicCompactService under the real invariants plugin', () => {
     await ctx.plugin(Invariants, {})
     await ctx.plugin(LlmService)
     ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED'))
-    await ctx.plugin(BasicCompactService, { auto: false })
+    await ctx.plugin(BasicCompactService, cfg({ auto: false }))
     const session = ctx.sessions.create()
     return { ctx, session, svc: ctx.compact as BasicCompactService }
   }

+ 3 - 0
packages/compact/compact-basic/tests/compact-loop-repro.spec.ts

@@ -97,6 +97,9 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
     contextWindow: 64,
     thresholdRatio: 0.5,
     retainTokens: 20,
+    summarizationModel: '',
+    maxTokens: 8192,
+    compactionRetries: 1,
   })
   return { ctx, compact }
 }

+ 5 - 0
packages/core/agent/src/types.ts

@@ -206,6 +206,11 @@ declare module 'cordis' {
      * summarization model call).
      * @mode serial
      */
+    // TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
+    // is its only consumer, so a wide event carries a string just one listener
+    // reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
+    // prompt provider, or move token-pressure measurement behind a
+    // compaction-specific seam instead of the shared pre-step checkpoint.
     'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
     /**
      * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the