Skip to content
Draft
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
17 changes: 17 additions & 0 deletions src/core/prompts/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ Otherwise, if you have not completed the task and do not need additional informa
(This is an automated message, so do not respond to it conversationally.)`
},

reasoningRepetitionDetected: () => {
const instructions = getToolInstructionsReminder()

return `[ERROR] Your reasoning/thinking output was stuck in a repetitive loop, repeating the same lines over and over. The response was aborted to save tokens.

IMPORTANT: Do NOT repeat the same thoughts or plans. Take a different approach or proceed directly with action.

${instructions}

# Next Steps

If you have completed the user's task, use the attempt_completion tool.
If you require additional information from the user, use the ask_followup_question tool.
Otherwise, proceed with the next step of the task using a tool call. Do NOT repeat your previous reasoning.
(This is an automated message, so do not respond to it conversationally.)`
},

tooManyMistakes: (feedback?: string) =>
JSON.stringify({
status: "guidance",
Expand Down
47 changes: 47 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools"

// core modules
import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
import { ReasoningRepetitionDetector } from "../tools/ReasoningRepetitionDetector"
import { restoreTodoListForTask } from "../tools/UpdateTodoListTool"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
Expand Down Expand Up @@ -298,6 +299,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

toolRepetitionDetector: ToolRepetitionDetector
reasoningRepetitionDetector: ReasoningRepetitionDetector
reasoningRepetitionAborted: boolean = false
rooIgnoreController?: RooIgnoreController
rooProtectedController?: RooProtectedController
fileContextTracker: FileContextTracker
Expand Down Expand Up @@ -689,6 +692,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.diffStrategy = new MultiSearchReplaceDiffStrategy()

this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit)
this.reasoningRepetitionDetector = new ReasoningRepetitionDetector()

// Initialize todo list if provided
if (initialTodos && initialTodos.length > 0) {
Expand Down Expand Up @@ -2935,6 +2939,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.didToolFailInCurrentTurn = false
this.presentAssistantMessageLocked = false
this.presentAssistantMessageHasPendingUpdates = false
this.reasoningRepetitionDetector.reset()
this.reasoningRepetitionAborted = false
// No legacy text-stream tool parser.
this.streamingToolCallIndices.clear()
// Clear any leftover streaming tool call state from previous interrupted streams
Expand Down Expand Up @@ -2997,6 +3003,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
switch (chunk.type) {
case "reasoning": {
reasoningMessage += chunk.text

// Detect repetitive reasoning during streaming to abort early
// and save tokens when the model gets stuck in a loop.
if (this.reasoningRepetitionDetector.addChunk(chunk.text)) {
console.warn(
`[Task#${this.taskId}.${this.instanceId}] Reasoning repetition detected, aborting stream`,
)
await this.say(
"error",
"Repetitive reasoning detected - the model's thinking got stuck in a loop. Aborting to save tokens.",
)
this.reasoningRepetitionAborted = true
this.cancelCurrentRequest()
break
}

// Only apply formatting if the message contains sentence-ending punctuation followed by **
let formattedReasoning = reasoningMessage
if (reasoningMessage.includes("**")) {
Expand Down Expand Up @@ -3310,6 +3332,31 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Cline instance to finish aborting (error is thrown here when
// any function in the for loop throws due to this.abort).
if (!this.abandoned) {
// Check if this abort was triggered by reasoning repetition detection.
// In this case we don't retry - instead we continue the task loop with
// a guidance message to break the model out of the loop.
if (this.reasoningRepetitionAborted) {
this.reasoningRepetitionAborted = false
this.consecutiveMistakeCount++

// Clean up partial state without treating it as a streaming failure
await abortStream("streaming_failed")

// Push guidance message onto the stack so the model gets feedback
// about the repetition and can try a different approach
stack.push({
userContent: [
{
type: "text" as const,
text: formatResponse.reasoningRepetitionDetected(),
},
],
includeFileDetails: false,
})

continue
}

// Determine cancellation reason
const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed"

Expand Down
125 changes: 125 additions & 0 deletions src/core/tools/ReasoningRepetitionDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Detects repetitive patterns in model reasoning/thinking output during streaming.
*
* Some models (particularly Gemini) can get stuck in a loop where their
* thinking/reasoning output repeats the same lines over and over, e.g.:
* "I'll mention that I verified with tests."
* "I'll mention that I reverted the tests."
* "I'll mention that I verified with tests."
* "I'll mention that I reverted the tests."
* ...
*
* This detector tracks lines as they stream in and flags when any single
* line has been repeated more than the configured threshold.
*/
export class ReasoningRepetitionDetector {
private lineCounts: Map<string, number> = new Map()
private buffer: string = ""
private readonly repetitionThreshold: number
private readonly minLineLength: number

/**
* @param repetitionThreshold Number of times a line must repeat to be considered a loop (default: 5)
* @param minLineLength Minimum line length to track - short lines are ignored (default: 20)
*/
constructor(repetitionThreshold: number = 5, minLineLength: number = 20) {
this.repetitionThreshold = repetitionThreshold
this.minLineLength = minLineLength
}

/**
* Feed a new chunk of reasoning text and check for repetition.
*
* @param chunk A new piece of reasoning/thinking text from the stream
* @returns true if repetitive looping has been detected
*/
public addChunk(chunk: string): boolean {
this.buffer += chunk

// Split buffer into complete lines (keeping incomplete last line in buffer)
const lines = this.buffer.split("\n")

// Keep the last element as the buffer (it may be an incomplete line)
this.buffer = lines.pop() ?? ""

for (const rawLine of lines) {
const line = this.normalizeLine(rawLine)

if (line.length < this.minLineLength) {
continue
}

const count = (this.lineCounts.get(line) ?? 0) + 1
this.lineCounts.set(line, count)

if (count >= this.repetitionThreshold) {
return true
}
}

return false
}

/**
* Check if any line in the accumulated reasoning has hit the repetition threshold.
* Useful for checking after a stream is complete but before tool processing.
*/
public isRepetitive(): boolean {
// Also process any remaining buffer content
if (this.buffer.length > 0) {
const line = this.normalizeLine(this.buffer)
if (line.length >= this.minLineLength) {
const count = (this.lineCounts.get(line) ?? 0) + 1
this.lineCounts.set(line, count)
if (count >= this.repetitionThreshold) {
return true
}
}
}

for (const count of this.lineCounts.values()) {
if (count >= this.repetitionThreshold) {
return true
}
}

return false
}
Comment on lines +67 to +87
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRepetitive() mutates lineCounts as a side effect: each call increments the count for whatever is sitting in the buffer. If this method is called more than once without an intervening addChunk() or reset(), the buffered line's count keeps climbing, and can cross the threshold even though the line only appeared once in the actual stream. Although the current Task.ts integration only uses addChunk(), this is a public query method whose semantics suggest it should be idempotent.

One fix: process the buffer into a local variable instead of writing back to lineCounts, or clear the buffer after processing it.

Suggested change
public isRepetitive(): boolean {
// Also process any remaining buffer content
if (this.buffer.length > 0) {
const line = this.normalizeLine(this.buffer)
if (line.length >= this.minLineLength) {
const count = (this.lineCounts.get(line) ?? 0) + 1
this.lineCounts.set(line, count)
if (count >= this.repetitionThreshold) {
return true
}
}
}
for (const count of this.lineCounts.values()) {
if (count >= this.repetitionThreshold) {
return true
}
}
return false
}
public isRepetitive(): boolean {
// Also process any remaining buffer content
if (this.buffer.length > 0) {
const line = this.normalizeLine(this.buffer)
if (line.length >= this.minLineLength) {
const count = (this.lineCounts.get(line) ?? 0) + 1
if (count >= this.repetitionThreshold) {
return true
}
}
}
for (const count of this.lineCounts.values()) {
if (count >= this.repetitionThreshold) {
return true
}
}
return false
}

Fix it with Roo Code or mention @roomote and request a fix.


/**
* Get the most repeated line and its count, useful for diagnostics.
*/
public getMostRepeatedLine(): { line: string; count: number } | undefined {
let maxLine: string | undefined
let maxCount = 0

for (const [line, count] of this.lineCounts.entries()) {
if (count > maxCount) {
maxCount = count
maxLine = line
}
}

if (maxLine !== undefined) {
return { line: maxLine, count: maxCount }
}

return undefined
}

/**
* Reset the detector state. Called at the start of each new API request.
*/
public reset(): void {
this.lineCounts.clear()
this.buffer = ""
}

/**
* Normalize a line for comparison: trim whitespace, collapse internal
* whitespace, and lowercase.
*/
private normalizeLine(line: string): string {
return line.trim().replace(/\s+/g, " ").toLowerCase()
}
}
Loading
Loading