diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 6a43db0b0e..d6a34d48ed 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/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 diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 82253a0aef..e5bbea2811 100644 --- a/examples/coding-agent/cordis.yml +++ b/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 diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index a300aac69a..39483dc848 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/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', }) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 5b2e177997..c195ae42fb 100644 --- a/packages/compact/compact-basic/README.md +++ b/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, + }) } ``` diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f7fdadb3fa..f53dace461 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/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. diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 8c4753c84f..98195d8883 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/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 -/** 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) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 10aae6d8be..1929e656be 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/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 { + 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 = {}): 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), + )).toThrow(/summarizationModel must be a string/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial))) .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 } } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index dddb636b21..319e30a73c 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/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 } } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index fc412f0e0e..407cce5250 100644 --- a/packages/core/agent/src/types.ts +++ b/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 /** * Waterfall: mutate the fully-assembled {@link GenerateOptions} before the