Skip to content

Commit 88b3079

Browse files
committed
feat: add minimum token requirement for intelligent condensing
- Add minimumCondenseTokens configuration option to global settings - Implement iterative refinement in summarizeConversation to meet minimum token requirements - Add expandSummaryToMeetMinimum function for multi-request expansion - Update sliding-window and Task modules to pass minimum token configuration - Add comprehensive tests for the new functionality - Ensure backward compatibility for existing use cases Fixes #7644
1 parent c25cfde commit 88b3079

File tree

6 files changed

+578
-0
lines changed

6 files changed

+578
-0
lines changed

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const globalSettingsSchema = z.object({
7575
allowedMaxCost: z.number().nullish(),
7676
autoCondenseContext: z.boolean().optional(),
7777
autoCondenseContextPercent: z.number().optional(),
78+
minimumCondenseTokens: z.number().optional(), // Minimum tokens for condensed output
7879
maxConcurrentFileReads: z.number().optional(),
7980

8081
/**

src/core/condense/__tests__/index.spec.ts

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,3 +792,385 @@ describe("summarizeConversation with custom settings", () => {
792792
)
793793
})
794794
})
795+
796+
describe("summarizeConversation with minimum token requirements", () => {
797+
// Mock ApiHandler
798+
let mockApiHandler: ApiHandler
799+
let mockCondensingApiHandler: ApiHandler
800+
const defaultSystemPrompt = "You are a helpful assistant."
801+
const taskId = "test-task-id"
802+
803+
// Sample messages for testing
804+
const sampleMessages: ApiMessage[] = [
805+
{ role: "user", content: "Hello", ts: 1 },
806+
{ role: "assistant", content: "Hi there", ts: 2 },
807+
{ role: "user", content: "How are you?", ts: 3 },
808+
{ role: "assistant", content: "I'm good", ts: 4 },
809+
{ role: "user", content: "What's new?", ts: 5 },
810+
{ role: "assistant", content: "Not much", ts: 6 },
811+
{ role: "user", content: "Tell me more", ts: 7 },
812+
]
813+
814+
beforeEach(() => {
815+
// Reset mocks
816+
vi.clearAllMocks()
817+
818+
// Setup mock API handler
819+
mockApiHandler = {
820+
createMessage: vi.fn(),
821+
countTokens: vi.fn(),
822+
getModel: vi.fn().mockReturnValue({
823+
id: "test-model",
824+
info: {
825+
contextWindow: 8000,
826+
supportsImages: true,
827+
supportsComputerUse: true,
828+
supportsVision: true,
829+
maxTokens: 4000,
830+
supportsPromptCache: true,
831+
maxCachePoints: 10,
832+
minTokensPerCachePoint: 100,
833+
cachableFields: ["system", "messages"],
834+
},
835+
}),
836+
} as unknown as ApiHandler
837+
838+
mockCondensingApiHandler = {
839+
createMessage: vi.fn(),
840+
countTokens: vi.fn(),
841+
getModel: vi.fn().mockReturnValue({
842+
id: "condensing-model",
843+
info: {
844+
contextWindow: 4000,
845+
supportsImages: true,
846+
supportsComputerUse: false,
847+
supportsVision: false,
848+
maxTokens: 2000,
849+
supportsPromptCache: false,
850+
maxCachePoints: 0,
851+
minTokensPerCachePoint: 0,
852+
cachableFields: [],
853+
},
854+
}),
855+
} as unknown as ApiHandler
856+
})
857+
858+
it("should not expand summary when minimum tokens is not specified", async () => {
859+
// Setup initial summary stream
860+
const initialStream = (async function* () {
861+
yield { type: "text" as const, text: "Short summary" }
862+
yield { type: "usage" as const, totalCost: 0.02, outputTokens: 50 }
863+
})()
864+
865+
mockApiHandler.createMessage = vi.fn().mockReturnValueOnce(initialStream) as any
866+
mockApiHandler.countTokens = vi.fn().mockResolvedValue(100) as any
867+
868+
const result = await summarizeConversation(
869+
sampleMessages,
870+
mockApiHandler,
871+
defaultSystemPrompt,
872+
taskId,
873+
1000, // prevContextTokens
874+
false,
875+
undefined,
876+
undefined,
877+
undefined, // No minimum tokens specified
878+
)
879+
880+
// Should only call createMessage once (no expansion)
881+
expect(mockApiHandler.createMessage).toHaveBeenCalledTimes(1)
882+
expect(result.summary).toBe("Short summary")
883+
expect(result.newContextTokens).toBe(150) // 50 output + 100 counted
884+
expect(result.error).toBeUndefined()
885+
})
886+
887+
it("should not expand summary when current tokens already meet minimum requirement", async () => {
888+
// Setup initial summary stream with enough tokens
889+
const initialStream = (async function* () {
890+
yield { type: "text" as const, text: "This is a longer summary with more content" }
891+
yield { type: "usage" as const, totalCost: 0.05, outputTokens: 300 }
892+
})()
893+
894+
mockApiHandler.createMessage = vi.fn().mockReturnValueOnce(initialStream) as any
895+
mockApiHandler.countTokens = vi.fn().mockResolvedValue(250) as any
896+
897+
const result = await summarizeConversation(
898+
sampleMessages,
899+
mockApiHandler,
900+
defaultSystemPrompt,
901+
taskId,
902+
1000, // prevContextTokens
903+
false,
904+
undefined,
905+
undefined,
906+
500, // minimumCondenseTokens - already met by 550 total
907+
)
908+
909+
// Should only call createMessage once (no expansion needed)
910+
expect(mockApiHandler.createMessage).toHaveBeenCalledTimes(1)
911+
expect(result.summary).toBe("This is a longer summary with more content")
912+
expect(result.newContextTokens).toBe(550) // 300 output + 250 counted
913+
expect(result.error).toBeUndefined()
914+
})
915+
916+
it("should expand summary when below minimum token requirement", async () => {
917+
// Setup initial summary stream with too few tokens
918+
const initialStream = (async function* () {
919+
yield { type: "text" as const, text: "Short summary" }
920+
yield { type: "usage" as const, totalCost: 0.02, outputTokens: 50 }
921+
})()
922+
923+
// Setup expansion stream
924+
const expansionStream = (async function* () {
925+
yield {
926+
type: "text" as const,
927+
text: "This is a much more detailed and expanded summary with lots of additional context and information",
928+
}
929+
yield { type: "usage" as const, totalCost: 0.08, outputTokens: 400 }
930+
})()
931+
932+
mockApiHandler.createMessage = vi
933+
.fn()
934+
.mockReturnValueOnce(initialStream)
935+
.mockReturnValueOnce(expansionStream) as any
936+
937+
mockApiHandler.countTokens = vi
938+
.fn()
939+
.mockResolvedValueOnce(100) // First count after initial summary
940+
.mockResolvedValueOnce(150) // Count after first expansion attempt
941+
.mockResolvedValueOnce(150) as any // Count after second expansion attempt (final)
942+
943+
const result = await summarizeConversation(
944+
sampleMessages,
945+
mockApiHandler,
946+
defaultSystemPrompt,
947+
taskId,
948+
1000, // prevContextTokens
949+
false,
950+
undefined,
951+
undefined,
952+
500, // minimumCondenseTokens - requires expansion
953+
)
954+
955+
// Should call createMessage three times (initial + 2 expansions due to mock setup)
956+
expect(mockApiHandler.createMessage).toHaveBeenCalledTimes(3)
957+
958+
// Check the expansion request includes the expansion prompt
959+
const secondCall = (mockApiHandler.createMessage as Mock).mock.calls[1]
960+
const expansionMessages = secondCall[1]
961+
const lastMessage = expansionMessages[expansionMessages.length - 1]
962+
expect(lastMessage.content).toContain("The current summary has")
963+
expect(lastMessage.content).toContain("tokens, but we need at least")
964+
965+
expect(result.summary).toBe(
966+
"This is a much more detailed and expanded summary with lots of additional context and information",
967+
)
968+
expect(result.newContextTokens).toBe(150) // Final count from mock
969+
expect(result.cost).toBe(0.1) // 0.02 + 0.08
970+
expect(result.error).toBeUndefined()
971+
})
972+
973+
it("should use condensing API handler for expansion when provided", async () => {
974+
// Setup initial summary stream with too few tokens
975+
const initialStream = (async function* () {
976+
yield { type: "text" as const, text: "Short summary" }
977+
yield { type: "usage" as const, totalCost: 0.02, outputTokens: 50 }
978+
})()
979+
980+
// Setup expansion stream from condensing handler
981+
const expansionStream = (async function* () {
982+
yield { type: "text" as const, text: "Expanded summary from condensing handler" }
983+
yield { type: "usage" as const, totalCost: 0.06, outputTokens: 350 }
984+
})()
985+
986+
mockCondensingApiHandler.createMessage = vi
987+
.fn()
988+
.mockReturnValueOnce(initialStream)
989+
.mockReturnValueOnce(expansionStream) as any
990+
991+
mockApiHandler.countTokens = vi
992+
.fn()
993+
.mockResolvedValueOnce(100) // First count
994+
.mockResolvedValueOnce(200) // After first expansion
995+
.mockResolvedValueOnce(200) as any // After second expansion (final)
996+
997+
const result = await summarizeConversation(
998+
sampleMessages,
999+
mockApiHandler,
1000+
defaultSystemPrompt,
1001+
taskId,
1002+
1000, // prevContextTokens
1003+
false,
1004+
"Custom prompt",
1005+
mockCondensingApiHandler,
1006+
500, // minimumCondenseTokens
1007+
)
1008+
1009+
// Should use condensing handler for all calls (initial + expansions)
1010+
expect(mockCondensingApiHandler.createMessage).toHaveBeenCalledTimes(3)
1011+
expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
1012+
1013+
expect(result.summary).toBe("Expanded summary from condensing handler")
1014+
expect(result.newContextTokens).toBe(200) // Final count from mock
1015+
expect(result.cost).toBe(0.08) // 0.02 + 0.06
1016+
expect(result.error).toBeUndefined()
1017+
})
1018+
1019+
it("should stop expansion after MAX_ITERATIONS to prevent infinite loops", async () => {
1020+
// Setup streams that always return insufficient tokens
1021+
const createSmallStream = () =>
1022+
(async function* () {
1023+
yield { type: "text" as const, text: "Still too short" }
1024+
yield { type: "usage" as const, totalCost: 0.01, outputTokens: 30 }
1025+
})()
1026+
1027+
let callCount = 0
1028+
mockApiHandler.createMessage = vi.fn().mockImplementation(() => {
1029+
callCount++
1030+
return createSmallStream()
1031+
}) as any
1032+
1033+
// Always return low token count
1034+
mockApiHandler.countTokens = vi.fn().mockResolvedValue(50) as any
1035+
1036+
const result = await summarizeConversation(
1037+
sampleMessages,
1038+
mockApiHandler,
1039+
defaultSystemPrompt,
1040+
taskId,
1041+
2000, // prevContextTokens
1042+
false,
1043+
undefined,
1044+
undefined,
1045+
1000, // minimumCondenseTokens - impossible to reach
1046+
)
1047+
1048+
// Should stop after MAX_ITERATIONS (5) + 1 initial call = 6 total
1049+
expect(mockApiHandler.createMessage).toHaveBeenCalledTimes(6)
1050+
expect(result.summary).toBe("Still too short")
1051+
expect(result.error).toBeUndefined()
1052+
})
1053+
1054+
it("should revert to previous summary if expansion exceeds context limit", async () => {
1055+
// Setup initial summary stream
1056+
const initialStream = (async function* () {
1057+
yield { type: "text" as const, text: "Initial summary" }
1058+
yield { type: "usage" as const, totalCost: 0.02, outputTokens: 100 }
1059+
})()
1060+
1061+
// Setup expansion stream that's too large
1062+
const expansionStream = (async function* () {
1063+
yield { type: "text" as const, text: "Extremely long expanded summary that exceeds context" }
1064+
yield { type: "usage" as const, totalCost: 0.1, outputTokens: 800 }
1065+
})()
1066+
1067+
mockApiHandler.createMessage = vi
1068+
.fn()
1069+
.mockReturnValueOnce(initialStream)
1070+
.mockReturnValueOnce(expansionStream) as any
1071+
1072+
mockApiHandler.countTokens = vi
1073+
.fn()
1074+
.mockResolvedValueOnce(150) // First count
1075+
.mockResolvedValueOnce(500) as any // After expansion - too large!
1076+
1077+
const result = await summarizeConversation(
1078+
sampleMessages,
1079+
mockApiHandler,
1080+
defaultSystemPrompt,
1081+
taskId,
1082+
400, // prevContextTokens - will be exceeded
1083+
false,
1084+
undefined,
1085+
undefined,
1086+
300, // minimumCondenseTokens
1087+
)
1088+
1089+
// Should revert to initial summary
1090+
expect(result.summary).toBe("Initial summary")
1091+
expect(result.newContextTokens).toBe(250) // 100 output + 150 counted (initial)
1092+
expect(result.cost).toBeCloseTo(0.02, 5) // Only initial cost, expansion cost excluded
1093+
expect(result.error).toBeUndefined()
1094+
})
1095+
1096+
it("should handle empty expansion response gracefully", async () => {
1097+
// Setup initial summary stream
1098+
const initialStream = (async function* () {
1099+
yield { type: "text" as const, text: "Initial summary" }
1100+
yield { type: "usage" as const, totalCost: 0.02, outputTokens: 100 }
1101+
})()
1102+
1103+
// Setup empty expansion stream
1104+
const emptyExpansionStream = (async function* () {
1105+
yield { type: "text" as const, text: "" }
1106+
yield { type: "usage" as const, totalCost: 0.01, outputTokens: 0 }
1107+
})()
1108+
1109+
mockApiHandler.createMessage = vi
1110+
.fn()
1111+
.mockReturnValueOnce(initialStream)
1112+
.mockReturnValueOnce(emptyExpansionStream) as any
1113+
1114+
mockApiHandler.countTokens = vi.fn().mockResolvedValue(150) as any
1115+
1116+
const result = await summarizeConversation(
1117+
sampleMessages,
1118+
mockApiHandler,
1119+
defaultSystemPrompt,
1120+
taskId,
1121+
1000, // prevContextTokens
1122+
false,
1123+
undefined,
1124+
undefined,
1125+
500, // minimumCondenseTokens - requires expansion but gets empty response
1126+
)
1127+
1128+
// Should keep initial summary when expansion fails
1129+
expect(result.summary).toBe("Initial summary")
1130+
expect(result.newContextTokens).toBe(250) // 100 output + 150 counted
1131+
expect(result.cost).toBe(0.02) // Only initial cost since expansion produced empty result
1132+
expect(result.error).toBeUndefined()
1133+
})
1134+
1135+
it("should use custom prompt for expansion when provided", async () => {
1136+
const customPrompt = "Custom summarization instructions"
1137+
1138+
// Setup initial summary stream with too few tokens
1139+
const initialStream = (async function* () {
1140+
yield { type: "text" as const, text: "Short summary" }
1141+
yield { type: "usage" as const, totalCost: 0.02, outputTokens: 50 }
1142+
})()
1143+
1144+
// Setup expansion stream
1145+
const expansionStream = (async function* () {
1146+
yield { type: "text" as const, text: "Expanded with custom prompt" }
1147+
yield { type: "usage" as const, totalCost: 0.05, outputTokens: 300 }
1148+
})()
1149+
1150+
mockApiHandler.createMessage = vi
1151+
.fn()
1152+
.mockReturnValueOnce(initialStream)
1153+
.mockReturnValueOnce(expansionStream) as any
1154+
1155+
mockApiHandler.countTokens = vi.fn().mockResolvedValueOnce(100).mockResolvedValueOnce(200) as any
1156+
1157+
const result = await summarizeConversation(
1158+
sampleMessages,
1159+
mockApiHandler,
1160+
defaultSystemPrompt,
1161+
taskId,
1162+
1000,
1163+
false,
1164+
customPrompt, // Custom prompt provided
1165+
undefined,
1166+
400, // minimumCondenseTokens
1167+
)
1168+
1169+
// Check that custom prompt was used in expansion
1170+
const expansionCall = (mockApiHandler.createMessage as Mock).mock.calls[1]
1171+
expect(expansionCall[0]).toBe(customPrompt)
1172+
1173+
expect(result.summary).toBe("Expanded with custom prompt")
1174+
expect(result.error).toBeUndefined()
1175+
})
1176+
})

0 commit comments

Comments
 (0)