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.
This commit is contained in:
Tianyi Cui
2026-07-01 22:10:49 +08:00
parent 1a5302dbcf
commit 5252477bc9
9 changed files with 139 additions and 97 deletions
+18 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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)
@@ -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 }
}
@@ -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 }
}