From 002355f8b59a7318f737d9c39ef1d31c8da6289b Mon Sep 17 00:00:00 2001 From: im47cn <67424112+im47cn@users.noreply.github.com> Date: Tue, 18 Mar 2025 05:05:37 +0800 Subject: [PATCH] feat: enhance conversation truncation logic to preserve dialog integrity and handle large message counts --- src/core/Cline.ts | 236 +++++++++++++++--- .../__tests__/sliding-window.test.ts | 88 ++++++- src/core/sliding-window/index.ts | 70 +++++- 3 files changed, 347 insertions(+), 47 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 75f239e4418..42b9344d278 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -122,6 +122,10 @@ export class Cline extends EventEmitter { // Subtasks readonly rootTask: Cline | undefined = undefined readonly parentTask: Cline | undefined = undefined + + // A timer used to save history for anti-shake + private _saveApiConversationHistoryTimeout?: NodeJS.Timeout + private _saveClineMessagesTimeout?: NodeJS.Timeout readonly taskNumber: number private isPaused: boolean = false private pausedModeSlug: string = defaultModeSlug @@ -313,8 +317,85 @@ export class Cline extends EventEmitter { private async saveApiConversationHistory() { try { - const filePath = path.join(await this.ensureTaskDirectoryExists(), GlobalFileNames.apiConversationHistory) - await fs.writeFile(filePath, JSON.stringify(this.apiConversationHistory)) + // Add anti-shake to prevent frequent history saving + if (this._saveApiConversationHistoryTimeout) { + clearTimeout(this._saveApiConversationHistoryTimeout) + } + + this._saveApiConversationHistoryTimeout = setTimeout(async () => { + const filePath = path.join( + await this.ensureTaskDirectoryExists(), + GlobalFileNames.apiConversationHistory, + ) + + // Check the history length, log and truncate + if (this.apiConversationHistory.length > 50) { + console.log( + `Long API conversation history detected: ${this.apiConversationHistory.length} messages, performing truncation before saving`, + ) + + // Record memory usage + try { + const memoryUsage = process.memoryUsage() + console.log( + `Memory usage before truncation: RSS=${Math.round(memoryUsage.rss / 1024 / 1024)}MB, Heap=${Math.round(memoryUsage.heapUsed / 1024 / 1024)}/${Math.round(memoryUsage.heapTotal / 1024 / 1024)}MB`, + ) + } catch (err) { + // Ignore the error, this is just diagnostic information + } + + // Retain the earliest system messages, user instructions, and some recent messages + const keepFirst = 5 + const keepLast = 50 + + if (this.apiConversationHistory.length > keepFirst + keepLast) { + const firstPart = this.apiConversationHistory.slice(0, keepFirst) + const lastPart = this.apiConversationHistory.slice(-keepLast) + const removedCount = this.apiConversationHistory.length - (firstPart.length + lastPart.length) + console.log( + `Truncating API conversation history: removed ${removedCount} messages, keeping ${firstPart.length} first and ${lastPart.length} last messages`, + ) + this.apiConversationHistory = [...firstPart, ...lastPart] + + // Enforce garbage collection + if (typeof global.gc === "function") { + try { + global.gc() + // The garbage collection is performed again after a delay of 100ms to ensure that the memory is completely released + setTimeout(() => { + if (typeof global.gc === "function") { + try { + global.gc() + console.log( + "Second manual garbage collection triggered to ensure memory release", + ) + } catch (err) { + // Ignore the error, this is just diagnostic information + } + } + }, 100) + console.log("Manual garbage collection triggered after history truncation") + } catch (err) { + // Ignore the error, this is just diagnostic information + } + } + + // Record memory usage + try { + const memoryUsage = process.memoryUsage() + console.log( + `Memory usage after truncation: RSS=${Math.round(memoryUsage.rss / 1024 / 1024)}MB, Heap=${Math.round(memoryUsage.heapUsed / 1024 / 1024)}/${Math.round(memoryUsage.heapTotal / 1024 / 1024)}MB`, + ) + } catch (err) { + // Ignore the error, this is just diagnostic information + } + } + } + + await fs.writeFile(filePath, JSON.stringify(this.apiConversationHistory)) + + this._saveApiConversationHistoryTimeout = undefined + }, 100) // Further reduce debounce delay to 100ms, speeding up saves and reducing delay risks } catch (error) { // in the off chance this fails, we don't want to stop the task console.error("Failed to save API conversation history:", error) @@ -362,42 +443,131 @@ export class Cline extends EventEmitter { private async saveClineMessages() { try { - const taskDir = await this.ensureTaskDirectoryExists() - const filePath = path.join(taskDir, GlobalFileNames.uiMessages) - await fs.writeFile(filePath, JSON.stringify(this.clineMessages)) - // combined as they are in ChatView - const apiMetrics = this.getTokenUsage() - const taskMessage = this.clineMessages[0] // first message is always the task say - const lastRelevantMessage = - this.clineMessages[ - findLastIndex( - this.clineMessages, - (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), + // Add anti-shake to prevent frequent saving of message history + if (this._saveClineMessagesTimeout) { + clearTimeout(this._saveClineMessagesTimeout) + } + + this._saveClineMessagesTimeout = setTimeout(async () => { + const taskDir = await this.ensureTaskDirectoryExists() + const filePath = path.join(taskDir, GlobalFileNames.uiMessages) + + // Check the message history length and perform a more aggressive truncation if it is too long + if (this.clineMessages.length > 50) { + console.log( + `Long UI message history detected: ${this.clineMessages.length} messages, trimming messages before saving`, ) - ] - let taskDirSize = 0 + // Record memory usage before truncation + try { + const memoryUsage = process.memoryUsage() + console.log( + `Memory usage before UI message truncation: RSS=${Math.round(memoryUsage.rss / 1024 / 1024)}MB, Heap=${Math.round(memoryUsage.heapUsed / 1024 / 1024)}/${Math.round(memoryUsage.heapTotal / 1024 / 1024)}MB`, + ) + } catch (err) { + // Ignore error + } - try { - taskDirSize = await getFolderSize.loose(taskDir) - } catch (err) { - console.error( - `[saveClineMessages] failed to get task directory size (${taskDir}): ${err instanceof Error ? err.message : String(err)}`, + // Keep the earliest user commands and some recent messages + const keepFirst = 3 + const keepLast = 40 + + if (this.clineMessages.length > keepFirst + keepLast) { + // Pre-truncation backups are no longer created to reduce memory usage + // const fullMessages = [...this.clineMessages]; + + const firstPart = this.clineMessages.slice(0, keepFirst) + const lastPart = this.clineMessages.slice(-keepLast) + const removedCount = this.clineMessages.length - (firstPart.length + lastPart.length) + console.log( + `Truncating UI message history: removed ${removedCount} messages, keeping ${firstPart.length} first and ${lastPart.length} last messages`, + ) + + // Update an array in memory + this.clineMessages = [...firstPart, ...lastPart] + + // Notify the front end to update the UI to prevent UI state from being inconsistent with the back end + this.providerRef + .deref() + ?.postStateToWebview() + .catch((err) => { + console.error("Failed to update webview after message truncation:", err) + }) + + // Force garbage collection to ensure that memory is freed + if (typeof global.gc === "function") { + try { + global.gc() + // The garbage collection is performed again after a delay of 100ms to ensure that the memory is completely released + setTimeout(() => { + if (typeof global.gc === "function") { + try { + global.gc() + console.log( + "Second manual garbage collection triggered to ensure memory release", + ) + } catch (err) { + // Ignore error + } + } + }, 100) + console.log("Manual garbage collection triggered after UI message truncation") + } catch (err) { + // Ignore error + } + } + + // Record memory usage after truncation + try { + const memoryUsage = process.memoryUsage() + console.log( + `Memory usage after UI message truncation: RSS=${Math.round(memoryUsage.rss / 1024 / 1024)}MB, Heap=${Math.round(memoryUsage.heapUsed / 1024 / 1024)}/${Math.round(memoryUsage.heapTotal / 1024 / 1024)}MB`, + ) + } catch (err) { + // Ignore error + } + } + } + + await fs.writeFile(filePath, JSON.stringify(this.clineMessages)) + // combined as they are in ChatView + const apiMetrics = getApiMetrics( + combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))), ) - } + const taskMessage = this.clineMessages[0] // first message is always the task say + const lastRelevantMessage = + this.clineMessages[ + findLastIndex( + this.clineMessages, + (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), + ) + ] - await this.providerRef.deref()?.updateTaskHistory({ - id: this.taskId, - number: this.taskNumber, - ts: lastRelevantMessage.ts, - task: taskMessage.text ?? "", - tokensIn: apiMetrics.totalTokensIn, - tokensOut: apiMetrics.totalTokensOut, - cacheWrites: apiMetrics.totalCacheWrites, - cacheReads: apiMetrics.totalCacheReads, - totalCost: apiMetrics.totalCost, - size: taskDirSize, - }) + let taskDirSize = 0 + + try { + taskDirSize = await getFolderSize.loose(taskDir) + } catch (err) { + console.error( + `[saveClineMessages] failed to get task directory size (${taskDir}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + + await this.providerRef.deref()?.updateTaskHistory({ + id: this.taskId, + number: this.taskNumber, + ts: lastRelevantMessage.ts, + task: taskMessage.text ?? "", + tokensIn: apiMetrics.totalTokensIn, + tokensOut: apiMetrics.totalTokensOut, + cacheWrites: apiMetrics.totalCacheWrites, + cacheReads: apiMetrics.totalCacheReads, + totalCost: apiMetrics.totalCost, + size: taskDirSize, + }) + + this._saveClineMessagesTimeout = undefined + }, 100) // Reduce buffering latency further to 100ms to speed up saving and reduce the risk of delays } catch (error) { console.error("Failed to save cline messages:", error) } diff --git a/src/core/sliding-window/__tests__/sliding-window.test.ts b/src/core/sliding-window/__tests__/sliding-window.test.ts index 532d00067ad..40dcf462f7a 100644 --- a/src/core/sliding-window/__tests__/sliding-window.test.ts +++ b/src/core/sliding-window/__tests__/sliding-window.test.ts @@ -67,10 +67,10 @@ describe("truncateConversation", () => { // 2 is already even, so no rounding needed const result = truncateConversation(messages, 0.5) - expect(result.length).toBe(3) + expect(result.length).toBe(5) expect(result[0]).toEqual(messages[0]) - expect(result[1]).toEqual(messages[3]) - expect(result[2]).toEqual(messages[4]) + expect(result[1]).toEqual(messages[1]) + expect(result[2]).toEqual(messages[2]) }) it("should round to an even number of messages to remove", () => { @@ -88,8 +88,50 @@ describe("truncateConversation", () => { // 1.8 rounds down to 1, then to 0 to make it even const result = truncateConversation(messages, 0.3) - expect(result.length).toBe(7) // No messages removed - expect(result).toEqual(messages) + expect(result.length).toBe(6) // No messages removed + expect(result[0]).toEqual(messages[0]) + expect(result[1]).toEqual(messages[2]) + expect(result[2]).toEqual(messages[3]) + expect(result[3]).toEqual(messages[4]) + expect(result[4]).toEqual(messages[5]) + expect(result[5]).toEqual(messages[6]) + }) + + it("should round to an 1 number of messages to remove", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "First message" }, + { role: "assistant", content: "Second message" }, + { role: "user", content: "Third message" }, + { role: "assistant", content: "Fourth message" }, + { role: "user", content: "Fifth message" }, + { role: "assistant", content: "Sixth message" }, + { role: "user", content: "Seventh message" }, + { role: "user", content: "Eighth message" }, + { role: "user", content: "Ninth message" }, + { role: "user", content: "Tenth message" }, + { role: "user", content: "Eleventh message" }, + { role: "user", content: "Twelfth message" }, + { role: "user", content: "Thirteenth message" }, + { role: "user", content: "Fourteenth message" }, + { role: "user", content: "Fifteenth message" }, + { role: "user", content: "Sixteenth message" }, + { role: "user", content: "Seventeenth message" }, + { role: "user", content: "Eighteenth message" }, + { role: "user", content: "Nineteenth message" }, + { role: "user", content: "Twentieth message" }, + ] + + // 6 messages excluding first, 0.3 fraction = 1.8 messages to remove + // 1.8 rounds down to 1, then to 0 to make it even + const result = truncateConversation(messages, 0.3) + + expect(result.length).toBe(17) // No messages removed + expect(result[0]).toEqual(messages[0]) + expect(result[1]).toEqual(messages[1]) + expect(result[2]).toEqual(messages[2]) + expect(result[4]).toEqual(messages[7]) + expect(result[5]).toEqual(messages[8]) + expect(result[result.length - 1]).toEqual(messages[19]) }) it("should handle edge case with fracToRemove = 0", () => { @@ -116,9 +158,11 @@ describe("truncateConversation", () => { // But 3 is odd, so it rounds down to 2 to make it even const result = truncateConversation(messages, 1) - expect(result.length).toBe(2) + expect(result.length).toBe(4) expect(result[0]).toEqual(messages[0]) - expect(result[1]).toEqual(messages[3]) + expect(result[1]).toEqual(messages[1]) + expect(result[2]).toEqual(messages[2]) + expect(result[3]).toEqual(messages[3]) }) }) @@ -215,6 +259,34 @@ describe("estimateTokenCount", () => { * Tests for the truncateConversationIfNeeded function */ describe("truncateConversationIfNeeded", () => { + it("should truncate when message count exceeds MAX_HISTORY_MESSAGES", async () => { + const MAX_HISTORY_MESSAGES = 100 + + const messages: Anthropic.Messages.MessageParam[] = [] + messages.push({ role: "user", content: "System message" }) // 系统消息 + + for (let i = 1; i <= MAX_HISTORY_MESSAGES; i++) { + messages.push({ role: i % 2 === 1 ? "user" : "assistant", content: `Message ${i}` }) + } + + expect(messages.length).toBeGreaterThan(MAX_HISTORY_MESSAGES) + + const result = await truncateConversationIfNeeded({ + messages, + totalTokens: 1000, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + }) + + expect(result.length).toBeLessThan(messages.length) + expect(result[0]).toEqual(messages[0]) + expect(result.length).toBeLessThan(MAX_HISTORY_MESSAGES) + + const expectedMaxLength = Math.ceil(messages.length * 0.3) + 1 // +1 因为第一条消息总是保留 + expect(result.length).toBeLessThanOrEqual(expectedMaxLength) + }) + const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({ contextWindow, supportsPromptCache: true, @@ -391,7 +463,7 @@ describe("truncateConversationIfNeeded", () => { it("should truncate if tokens are within TOKEN_BUFFER_PERCENTAGE of the threshold", async () => { const modelInfo = createModelInfo(100000, 30000) const maxTokens = 100000 - 30000 // 70000 - const dynamicBuffer = modelInfo.contextWindow * TOKEN_BUFFER_PERCENTAGE // 10% of 100000 = 10000 + const dynamicBuffer = modelInfo.contextWindow * TOKEN_BUFFER_PERCENTAGE // 10% of context window = 10000 const totalTokens = 70000 - dynamicBuffer + 1 // Just within the dynamic buffer of threshold (70000) // Create messages with very small content in the last one to avoid token overflow diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index 67c0028fab2..1afdc7cd966 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -35,13 +35,57 @@ export function truncateConversation( messages: Anthropic.Messages.MessageParam[], fracToRemove: number, ): Anthropic.Messages.MessageParam[] { - const truncatedMessages = [messages[0]] - const rawMessagesToRemove = Math.floor((messages.length - 1) * fracToRemove) - const messagesToRemove = rawMessagesToRemove - (rawMessagesToRemove % 2) - const remainingMessages = messages.slice(messagesToRemove + 1) - truncatedMessages.push(...remainingMessages) + if (messages.length <= 5) { + return messages + } - return truncatedMessages + // Remove messages from the beginning (excluding the first) + const firstMessage = messages[0] + const minMessagesToKeep = Math.max(4, Math.ceil((messages.length - 1) * (1 - fracToRemove))) + const lastMessages = messages.slice(-minMessagesToKeep) + + if (messages.length <= minMessagesToKeep + 1) { + return [firstMessage, ...lastMessages] + } + + // Remove messages from the middle + const middleStart = 1 + const middleEnd = messages.length - minMessagesToKeep + const middleMessagesCount = middleEnd - middleStart + const middleMessagesToKeep = Math.floor((middleMessagesCount * (1 - fracToRemove)) / 2) * 2 + + if (middleMessagesToKeep <= 0) { + console.log( + `Truncating aggressively: removed all ${middleMessagesCount} middle messages, keeping first message and ${lastMessages.length} last messages`, + ) + return [firstMessage, ...lastMessages] + } + + // Keep every `keepFrequency`-th message + const keepFrequency = Math.max(2, Math.floor(middleMessagesCount / middleMessagesToKeep)) + const middleMessages = [] + for (let i = middleStart; i < middleEnd; i += keepFrequency) { + if (i + 1 < middleEnd) { + middleMessages.push(messages[i], messages[i + 1]) + } else if (i < middleEnd) { + middleMessages.push(messages[i]) + } + + if (middleMessages.length >= middleMessagesToKeep) { + break + } + } + + const result = [firstMessage, ...middleMessages, ...lastMessages] + + console.log( + `Truncation stats: original=${messages.length}, truncated=${result.length}, removed=${messages.length - result.length}`, + ) + console.log( + `Truncation breakdown: kept first=1, middle=${middleMessages.length}/${middleMessagesCount}, last=${lastMessages.length}`, + ) + + return result } /** @@ -71,6 +115,8 @@ type TruncateOptions = { * @param {TruncateOptions} options - The options for truncation * @returns {Promise} The original or truncated conversation messages. */ +export const MAX_HISTORY_MESSAGES = 100 + export async function truncateConversationIfNeeded({ messages, totalTokens, @@ -78,6 +124,18 @@ export async function truncateConversationIfNeeded({ maxTokens, apiHandler, }: TruncateOptions): Promise { + if (messages.length > MAX_HISTORY_MESSAGES) { + console.log( + `Messages count (${messages.length}) severely exceeds threshold (${MAX_HISTORY_MESSAGES}), performing very aggressive truncation`, + ) + return truncateConversation(messages, 0.8) + } else if (messages.length > MAX_HISTORY_MESSAGES * 0.8) { + console.log( + `Messages count (${messages.length}) approaching threshold (${MAX_HISTORY_MESSAGES}), performing preemptive truncation`, + ) + return truncateConversation(messages, 0.6) + } + // Calculate the maximum tokens reserved for response const reservedTokens = maxTokens || contextWindow * 0.2