From 3d9e14d7482f4a99d5224926099bec0d17ff86fd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 11 May 2026 16:36:27 +0200 Subject: [PATCH 1/2] fix(compaction): clamp summary output tokens Fixes #4390. --- .../src/harness/compaction/compaction.ts | 10 ++++- .../agent/test/harness/compaction.test.ts | 37 ++++++++++++++++++- .../src/core/compaction/compaction.ts | 10 ++++- .../test/compaction-summary-reasoning.test.ts | 22 +++++++++-- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 0776222b..298dc86d 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -545,7 +545,10 @@ export async function generateSummary( previousSummary?: string, thinkingLevel?: ThinkingLevel, ): Promise { - const maxTokens = Math.floor(0.8 * reserveTokens); + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); // Use update prompt if we have a previous summary, otherwise initial prompt let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; @@ -817,7 +820,10 @@ async function generateTurnPrefixSummary( signal?: AbortSignal, thinkingLevel?: ThinkingLevel, ): Promise { - const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); // Smaller budget for turn prefix const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; diff --git a/packages/agent/test/harness/compaction.test.ts b/packages/agent/test/harness/compaction.test.ts index d73517f0..d39be48b 100644 --- a/packages/agent/test/harness/compaction.test.ts +++ b/packages/agent/test/harness/compaction.test.ts @@ -8,6 +8,7 @@ import { } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + type CompactionPreparation, calculateContextTokens, compact, DEFAULT_COMPACTION_SETTINGS, @@ -113,14 +114,17 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str }; } -function createFauxModel(reasoning: boolean): { faux: FauxProviderRegistration; model: Model } { +function createFauxModel( + reasoning: boolean, + maxTokens = 8192, +): { faux: FauxProviderRegistration; model: Model } { const faux = registerFauxProvider({ models: [ { id: reasoning ? "reasoning-model" : "non-reasoning-model", reasoning, contextWindow: 200000, - maxTokens: 8192, + maxTokens, }, ], }); @@ -285,6 +289,35 @@ describe("harness compaction", () => { expect(seenOptions[2]).not.toHaveProperty("reasoning"); }); + it("clamps compaction summary maxTokens to the model output cap", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + const seenOptions: Array | undefined> = []; + const { faux, model } = createFauxModel(false, 128000); + faux.setResponses([ + (_context, options) => { + seenOptions.push(options as Record | undefined); + return fauxAssistantMessage("## Goal\nTest summary"); + }, + (_context, options) => { + seenOptions.push(options as Record | undefined); + return fauxAssistantMessage("## Goal\nTest summary"); + }, + ]); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: messages, + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 600000, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 }, + }; + + await compact(preparation, model, "test-key"); + + expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]); + }); + it("returns a compaction result with file details", async () => { const u1 = createMessageEntry(createUserMessage("read a file")); const assistantMessage: AssistantMessage = { diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index c11c0254..5075e124 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -538,7 +538,10 @@ export async function generateSummary( previousSummary?: string, thinkingLevel?: ThinkingLevel, ): Promise { - const maxTokens = Math.floor(0.8 * reserveTokens); + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); // Use update prompt if we have a previous summary, otherwise initial prompt let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; @@ -808,7 +811,10 @@ async function generateTurnPrefixSummary( signal?: AbortSignal, thinkingLevel?: ThinkingLevel, ): Promise { - const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); // Smaller budget for turn prefix const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index c674ef1a..ea52016b 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -1,7 +1,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { generateSummary } from "../src/core/compaction/index.js"; +import { type CompactionPreparation, compact, generateSummary } from "../src/core/compaction/index.js"; const { completeSimpleMock } = vi.hoisted(() => ({ completeSimpleMock: vi.fn(), @@ -15,7 +15,7 @@ vi.mock("@earendil-works/pi-ai", async (importOriginal) => { }; }); -function createModel(reasoning: boolean): Model<"anthropic-messages"> { +function createModel(reasoning: boolean, maxTokens = 8192): Model<"anthropic-messages"> { return { id: reasoning ? "reasoning-model" : "non-reasoning-model", name: reasoning ? "Reasoning Model" : "Non-reasoning Model", @@ -26,7 +26,7 @@ function createModel(reasoning: boolean): Model<"anthropic-messages"> { input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, - maxTokens: 8192, + maxTokens, }; } @@ -115,4 +115,20 @@ describe("generateSummary reasoning options", () => { }); expect(completeSimpleMock.mock.calls[0][2]).not.toHaveProperty("reasoning"); }); + + it("clamps compaction summary maxTokens to the model output cap", async () => { + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: messages, + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 600000, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 }, + }; + + await compact(preparation, createModel(false, 128000), "test-key"); + + expect(completeSimpleMock.mock.calls.map((call) => call[2]?.maxTokens)).toEqual([128000, 128000]); + }); }); From 2726e57cda0a799ba98910458f99092eb0829883 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 May 2026 20:56:58 +0000 Subject: [PATCH 2/2] chore: approve contributor brianmichel --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 6f2193ab..50d3566e 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -205,3 +205,5 @@ npupko issue chrisvariety pr maximilianzuern pr + +brianmichel pr