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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user