Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 203 additions & 33 deletions src/core/Cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ export class Cline extends EventEmitter<ClineEvents> {
// 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
Expand Down Expand Up @@ -313,8 +317,85 @@ export class Cline extends EventEmitter<ClineEvents> {

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)
Expand Down Expand Up @@ -362,42 +443,131 @@ export class Cline extends EventEmitter<ClineEvents> {

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)
}
Expand Down
88 changes: 80 additions & 8 deletions src/core/sliding-window/__tests__/sliding-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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])
})
})

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading