Skip to content

Commit aff94e1

Browse files
authored
Merge pull request #3736 from RooVetGit/canyon/condense-metrics
[Condense Context] Track metrics around context condensing and show in UI
2 parents ce3e4e8 + 7df703f commit aff94e1

File tree

32 files changed

+949
-108
lines changed

32 files changed

+949
-108
lines changed

.changeset/fair-houses-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Shows in the UI when the context is intelligently condensed

evals/packages/types/src/roo-code.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,7 @@ export const clineSays = [
993993
"checkpoint_saved",
994994
"rooignore_error",
995995
"diff_error",
996+
"condense_context",
996997
] as const
997998

998999
export const clineSaySchema = z.enum(clineSays)
@@ -1011,6 +1012,18 @@ export const toolProgressStatusSchema = z.object({
10111012

10121013
export type ToolProgressStatus = z.infer<typeof toolProgressStatusSchema>
10131014

1015+
/**
1016+
* ContextCondense
1017+
*/
1018+
1019+
export const contextCondenseSchema = z.object({
1020+
cost: z.number(),
1021+
prevContextTokens: z.number(),
1022+
newContextTokens: z.number(),
1023+
})
1024+
1025+
export type ContextCondense = z.infer<typeof contextCondenseSchema>
1026+
10141027
/**
10151028
* ClineMessage
10161029
*/
@@ -1027,6 +1040,7 @@ export const clineMessageSchema = z.object({
10271040
conversationHistoryIndex: z.number().optional(),
10281041
checkpoint: z.record(z.string(), z.unknown()).optional(),
10291042
progressStatus: toolProgressStatusSchema.optional(),
1043+
contextCondense: contextCondenseSchema.optional(),
10301044
})
10311045

10321046
export type ClineMessage = z.infer<typeof clineMessageSchema>

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

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,11 @@ describe("summarizeConversation", () => {
6969
// Reset mocks
7070
jest.clearAllMocks()
7171

72-
// Setup mock stream
72+
// Setup mock stream with usage information
7373
mockStream = (async function* () {
7474
yield { type: "text" as const, text: "This is " }
7575
yield { type: "text" as const, text: "a summary" }
76+
yield { type: "usage" as const, totalCost: 0.05, outputTokens: 150 }
7677
})()
7778

7879
// Setup mock API handler
@@ -103,7 +104,10 @@ describe("summarizeConversation", () => {
103104
]
104105

105106
const result = await summarizeConversation(messages, mockApiHandler)
106-
expect(result).toEqual(messages)
107+
expect(result.messages).toEqual(messages)
108+
expect(result.cost).toBe(0)
109+
expect(result.summary).toBe("")
110+
expect(result.newContextTokens).toBeUndefined()
107111
expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
108112
})
109113

@@ -119,7 +123,10 @@ describe("summarizeConversation", () => {
119123
]
120124

121125
const result = await summarizeConversation(messages, mockApiHandler)
122-
expect(result).toEqual(messages)
126+
expect(result.messages).toEqual(messages)
127+
expect(result.cost).toBe(0)
128+
expect(result.summary).toBe("")
129+
expect(result.newContextTokens).toBeUndefined()
123130
expect(mockApiHandler.createMessage).not.toHaveBeenCalled()
124131
})
125132

@@ -142,17 +149,22 @@ describe("summarizeConversation", () => {
142149

143150
// Verify the structure of the result
144151
// The result should be: original messages (except last N) + summary + last N messages
145-
expect(result.length).toBe(messages.length + 1) // Original + summary
152+
expect(result.messages.length).toBe(messages.length + 1) // Original + summary
146153

147154
// Check that the summary message was inserted correctly
148-
const summaryMessage = result[result.length - N_MESSAGES_TO_KEEP - 1]
155+
const summaryMessage = result.messages[result.messages.length - N_MESSAGES_TO_KEEP - 1]
149156
expect(summaryMessage.role).toBe("assistant")
150157
expect(summaryMessage.content).toBe("This is a summary")
151158
expect(summaryMessage.isSummary).toBe(true)
152159

153160
// Check that the last N_MESSAGES_TO_KEEP messages are preserved
154161
const lastMessages = messages.slice(-N_MESSAGES_TO_KEEP)
155-
expect(result.slice(-N_MESSAGES_TO_KEEP)).toEqual(lastMessages)
162+
expect(result.messages.slice(-N_MESSAGES_TO_KEEP)).toEqual(lastMessages)
163+
164+
// Check the cost and token counts
165+
expect(result.cost).toBe(0.05)
166+
expect(result.summary).toBe("This is a summary")
167+
expect(result.newContextTokens).toBe(250) // 150 output tokens + 100 from countTokens
156168
})
157169

158170
it("should handle empty summary response", async () => {
@@ -172,9 +184,10 @@ describe("summarizeConversation", () => {
172184
const mockWarn = jest.fn()
173185
console.warn = mockWarn
174186

175-
// Setup empty summary response
187+
// Setup empty summary response with usage information
176188
const emptyStream = (async function* () {
177189
yield { type: "text" as const, text: "" }
190+
yield { type: "usage" as const, totalCost: 0.02, outputTokens: 0 }
178191
})()
179192

180193
// Create a new mock for createMessage that returns empty stream
@@ -189,7 +202,9 @@ describe("summarizeConversation", () => {
189202
const result = await summarizeConversation(messages, mockApiHandler)
190203

191204
// Should return original messages when summary is empty
192-
expect(result).toEqual(messages)
205+
expect(result.messages).toEqual(messages)
206+
expect(result.cost).toBe(0.02)
207+
expect(result.summary).toBe("")
193208
expect(mockWarn).toHaveBeenCalledWith("Received empty summary from API")
194209

195210
// Restore console.warn
@@ -225,4 +240,37 @@ describe("summarizeConversation", () => {
225240
const mockCallArgs = (maybeRemoveImageBlocks as jest.Mock).mock.calls[0][0] as any[]
226241
expect(mockCallArgs[mockCallArgs.length - 1]).toEqual(expectedFinalMessage)
227242
})
243+
244+
it("should calculate newContextTokens correctly with systemPrompt", async () => {
245+
const messages: ApiMessage[] = [
246+
{ role: "user", content: "Hello", ts: 1 },
247+
{ role: "assistant", content: "Hi there", ts: 2 },
248+
{ role: "user", content: "How are you?", ts: 3 },
249+
{ role: "assistant", content: "I'm good", ts: 4 },
250+
{ role: "user", content: "What's new?", ts: 5 },
251+
{ role: "assistant", content: "Not much", ts: 6 },
252+
{ role: "user", content: "Tell me more", ts: 7 },
253+
]
254+
255+
const systemPrompt = "You are a helpful assistant."
256+
257+
// Create a stream with usage information
258+
const streamWithUsage = (async function* () {
259+
yield { type: "text" as const, text: "This is a summary with system prompt" }
260+
yield { type: "usage" as const, totalCost: 0.06, outputTokens: 200 }
261+
})()
262+
263+
// Override the mock for this test
264+
mockApiHandler.createMessage = jest.fn().mockReturnValue(streamWithUsage) as any
265+
266+
const result = await summarizeConversation(messages, mockApiHandler, systemPrompt)
267+
268+
// Verify that countTokens was called with the correct messages including system prompt
269+
expect(mockApiHandler.countTokens).toHaveBeenCalled()
270+
271+
// Check the newContextTokens calculation includes system prompt
272+
expect(result.newContextTokens).toBe(300) // 200 output tokens + 100 from countTokens
273+
expect(result.cost).toBe(0.06)
274+
expect(result.summary).toBe("This is a summary with system prompt")
275+
})
228276
})

src/core/condense/index.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,23 +45,35 @@ Example summary structure:
4545
Output only the summary of the conversation so far, without any additional commentary or explanation.
4646
`
4747

48+
export type SummarizeResponse = {
49+
messages: ApiMessage[] // The messages after summarization
50+
summary: string // The summary text; empty string for no summary
51+
cost: number // The cost of the summarization operation
52+
newContextTokens?: number // The number of tokens in the context for the next API request
53+
}
54+
4855
/**
4956
* Summarizes the conversation messages using an LLM call
5057
*
5158
* @param {ApiMessage[]} messages - The conversation messages
5259
* @param {ApiHandler} apiHandler - The API handler to use for token counting.
53-
* @returns {ApiMessage[]} - The input messages, potentially including a new summary message before the last message.
60+
* @returns {SummarizeResponse} - The result of the summarization operation (see above)
5461
*/
55-
export async function summarizeConversation(messages: ApiMessage[], apiHandler: ApiHandler): Promise<ApiMessage[]> {
62+
export async function summarizeConversation(
63+
messages: ApiMessage[],
64+
apiHandler: ApiHandler,
65+
systemPrompt?: string,
66+
): Promise<SummarizeResponse> {
67+
const response: SummarizeResponse = { messages, cost: 0, summary: "" }
5668
const messagesToSummarize = getMessagesSinceLastSummary(messages.slice(0, -N_MESSAGES_TO_KEEP))
5769
if (messagesToSummarize.length <= 1) {
58-
return messages // Not enough messages to warrant a summary
70+
return response // Not enough messages to warrant a summary
5971
}
6072
const keepMessages = messages.slice(-N_MESSAGES_TO_KEEP)
61-
for (const message of keepMessages) {
62-
if (message.isSummary) {
63-
return messages // We recently summarized these messages; it's too soon to summarize again.
64-
}
73+
// Check if there's a recent summary in the messages we're keeping
74+
const recentSummaryExists = keepMessages.some((message) => message.isSummary)
75+
if (recentSummaryExists) {
76+
return response // We recently summarized these messages; it's too soon to summarize again.
6577
}
6678
const finalRequestMessage: Anthropic.MessageParam = {
6779
role: "user",
@@ -73,25 +85,41 @@ export async function summarizeConversation(messages: ApiMessage[], apiHandler:
7385
// Note: this doesn't need to be a stream, consider using something like apiHandler.completePrompt
7486
const stream = apiHandler.createMessage(SUMMARY_PROMPT, requestMessages)
7587
let summary = ""
76-
// TODO(canyon): compute usage and cost for this operation and update the global metrics.
88+
let cost = 0
89+
let outputTokens = 0
7790
for await (const chunk of stream) {
7891
if (chunk.type === "text") {
7992
summary += chunk.text
93+
} else if (chunk.type === "usage") {
94+
// Record final usage chunk only
95+
cost = chunk.totalCost ?? 0
96+
outputTokens = chunk.outputTokens ?? 0
8097
}
8198
}
8299
summary = summary.trim()
83100
if (summary.length === 0) {
84101
console.warn("Received empty summary from API")
85-
return messages
102+
return { ...response, cost }
86103
}
87104
const summaryMessage: ApiMessage = {
88105
role: "assistant",
89106
content: summary,
90107
ts: keepMessages[0].ts,
91108
isSummary: true,
92109
}
110+
const newMessages = [...messages.slice(0, -N_MESSAGES_TO_KEEP), summaryMessage, ...keepMessages]
93111

94-
return [...messages.slice(0, -N_MESSAGES_TO_KEEP), summaryMessage, ...keepMessages]
112+
// Count the tokens in the context for the next API request
113+
// We only estimate the tokens in summaryMesage if outputTokens is 0, otherwise we use outputTokens
114+
const contextMessages = outputTokens ? [...keepMessages] : [summaryMessage, ...keepMessages]
115+
if (systemPrompt) {
116+
contextMessages.unshift({ role: "user", content: systemPrompt })
117+
}
118+
const contextBlocks = contextMessages.flatMap((message) =>
119+
typeof message.content === "string" ? [{ text: message.content, type: "text" as const }] : message.content,
120+
)
121+
const newContextTokens = outputTokens + (await apiHandler.countTokens(contextBlocks))
122+
return { messages: newMessages, summary, cost, newContextTokens }
95123
}
96124

97125
/* Returns the list of all messages since the last summary message, including the summary. Returns all messages if there is no summary. */

0 commit comments

Comments
 (0)