diff --git a/src/context/compaction/CompactionEngine.ts b/src/context/compaction/CompactionEngine.ts index a182f69bf..991a5fc1e 100644 --- a/src/context/compaction/CompactionEngine.ts +++ b/src/context/compaction/CompactionEngine.ts @@ -51,7 +51,7 @@ export type CompactionEngineOptions = { model_: string; /** Optional summary system prompt base; the runtime always appends its summary rubric and safety constraints. */ systemPrompt?: string; - /** Max output tokens for the summary call (legacy default 20_000). */ + /** Max output tokens for the summary call. */ maxOutputTokens?: number; /** Tool names whose turns should be preserved verbatim across full compaction. */ protectedToolNames?: Iterable; @@ -65,9 +65,10 @@ export const COMPACT_SYSTEM_PROMPT_DEFAULT = "You are a conversation summarizer for a coding agent. Your summary will replace " + "the early conversation history, so it MUST preserve all information the agent " + "needs to continue working without repeating past steps."; -// Keep room for the summary input inside small configured contexts such as -// agent.maxContextTokens: 20_000. -export const COMPACT_MAX_OUTPUT_TOKENS = 4_000; +// Keep room for the summary input inside small configured contexts while still +// allowing enough output for a useful summary of a large transcript. +export const COMPACT_MAX_OUTPUT_TOKENS = 16_000; +const COMPACT_OUTPUT_RESERVE_CONTEXT_RATIO = 0.4; const SUMMARY_MARKDOWN_HEADINGS = [ "Objective", @@ -194,9 +195,19 @@ export class CompactionEngine { const cacheReset = input.cacheReset ?? checkpointMerged; const stablePrefix: CanonicalMessage[] = []; const planningMessages = checkpoint.liveMessages; - const summaryOutputReserve = positiveTokenCount(input.maxOutputTokens) + const requestedSummaryOutputReserve = positiveTokenCount(input.maxOutputTokens) ?? positiveTokenCount(this.options.maxOutputTokens) ?? COMPACT_MAX_OUTPUT_TOKENS; + const summaryContextCap = positiveTokenCount(input.effectiveContextTokens); + const summaryOutputReserve = summaryContextCap === undefined + ? requestedSummaryOutputReserve + : Math.max( + 1, + Math.min( + requestedSummaryOutputReserve, + Math.floor(summaryContextCap * COMPACT_OUTPUT_RESERVE_CONTEXT_RATIO), + ), + ); const targetPostTokens = positiveTokenCount(input.targetPostTokens); const tailRatio = clamp(input.keepTailRatio ?? DEFAULT_KEEP_TAIL_RATIO, 0, 1); const tailTokenBudget = targetPostTokens !== undefined @@ -265,7 +276,7 @@ export class CompactionEngine { input.userInstruction, input.signal, summaryAnchors, - input.maxOutputTokens, + summaryOutputReserve, checkpoint.previousSummaries, ); summaryMessage = wrapSummaryMessage(result.message); diff --git a/tests/context/compaction-engine.spec.ts b/tests/context/compaction-engine.spec.ts index 51036ac7d..c74280991 100644 --- a/tests/context/compaction-engine.spec.ts +++ b/tests/context/compaction-engine.spec.ts @@ -466,6 +466,43 @@ test("summary output truncated by the provider is treated as a failed compaction assert.equal(result.summaryGenerated, false); }); +test("summary output reserve scales with context while preserving explicit limits", async () => { + const summaryRequests: CanonicalModelRequest[] = []; + const engine = new CompactionEngine({ + model: { + async *stream(request: CanonicalModelRequest): AsyncIterable { + summaryRequests.push(request); + yield { type: "message_start", role: "assistant" }; + yield { type: "text_delta", text: "## Objective\nContinue.\n\n## Current State\nCompacted." }; + yield { type: "message_end", finishReason: "stop" }; + }, + }, + provider: "local", + model_: "local-chat", + }); + + const cases = [ + { effectiveContextTokens: 95_232, expected: 16_000 }, + { effectiveContextTokens: 20_000, expected: 8_000 }, + { expected: 16_000 }, + { effectiveContextTokens: 95_232, maxOutputTokens: 1_536, expected: 1_536 }, + ]; + for (const testCase of cases) { + await engine.run({ + trigger: "auto", + messages: compactFixture(), + keepTailRatio: 0.01, + effectiveContextTokens: testCase.effectiveContextTokens, + maxOutputTokens: testCase.maxOutputTokens, + }); + } + + assert.deepEqual( + summaryRequests.map((request) => request.maxOutputTokens), + cases.map((testCase) => testCase.expected), + ); +}); + test("protected turns remain in chronological order with later work", async () => { const engine = new CompactionEngine({ model: {