Compare commits

...
Author SHA1 Message Date
Robin Newhouse c6ed3c3bb8 fix: reset loop detector timer during extended thinking
Reasoning chunks now call onReasoningActivity() which resets the
elapsed-time clock without clearing the char count. This prevents
false positives where extended thinking (>60s) followed by legitimate
large text output would incorrectly trigger the loop detector.

Made-with: Cursor
2026-03-05 10:15:08 -08:00
Robin Newhouse c7b36ed518 fix: detect and abort in-generation text loops during streaming
Models (especially Gemini Flash) can enter degenerate text loops within
a single generation, producing thousands of lines of repetitive text
without ever emitting a tool call. This burns through context window
and causes timeouts.

Adds InGenerationLoopDetector that tracks text output and time since
last tool activity during streaming. Aborts the stream when both
thresholds are exceeded (15K chars AND 60s), truncates the garbage
text, and lets the existing noToolsUsed recovery path handle the retry.

Thresholds derived from analysis of 30 SWE-bench runs (20 passed,
10 failed/looping) with zero false positives.

Made-with: Cursor
2026-03-05 00:37:49 -08:00
3 changed files with 189 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import { Logger } from "@/shared/services/Logger"
const DEFAULT_CHAR_THRESHOLD = 15_000
const DEFAULT_TIME_THRESHOLD_MS = 60_000
/**
* Detects in-generation text loops where a model produces excessive text
* without ever emitting a tool call. Tracks both character count and elapsed
* time since the last tool-related activity — aborts only when both thresholds
* are exceeded to avoid false positives.
*/
export class InGenerationLoopDetector {
private lastToolActivityTime: number
private textLengthSinceLastTool = 0
constructor(
private readonly charThreshold = DEFAULT_CHAR_THRESHOLD,
private readonly timeThresholdMs = DEFAULT_TIME_THRESHOLD_MS,
private readonly now: () => number = Date.now,
) {
this.lastToolActivityTime = this.now()
}
onToolActivity(): void {
this.lastToolActivityTime = this.now()
this.textLengthSinceLastTool = 0
}
/** Reset the timer without clearing the char count — reasoning tokens aren't text, but shouldn't count toward elapsed time. */
onReasoningActivity(): void {
this.lastToolActivityTime = this.now()
}
onTextChunk(chunkLength: number): void {
this.textLengthSinceLastTool += chunkLength
}
isLooping(): boolean {
const elapsed = this.now() - this.lastToolActivityTime
if (this.textLengthSinceLastTool > this.charThreshold && elapsed > this.timeThresholdMs) {
Logger.info(
`[LoopDetection] Aborting stream: ${this.textLengthSinceLastTool} chars of text without tool activity in ${Math.round(elapsed / 1000)}s`,
)
return true
}
return false
}
}
@@ -0,0 +1,118 @@
import { describe, it } from "mocha"
import "should"
import { InGenerationLoopDetector } from "../InGenerationLoopDetector"
describe("InGenerationLoopDetector", () => {
function createDetector(opts: { charThreshold?: number; timeThresholdMs?: number; startTime?: number } = {}) {
let currentTime = opts.startTime ?? 0
const now = () => currentTime
const advance = (ms: number) => {
currentTime += ms
}
const detector = new InGenerationLoopDetector(opts.charThreshold ?? 15_000, opts.timeThresholdMs ?? 60_000, now)
return { detector, advance }
}
it("should not trigger when under both thresholds", () => {
const { detector, advance } = createDetector()
detector.onTextChunk(5_000)
advance(30_000)
detector.isLooping().should.be.false()
})
it("should not trigger when only char threshold is exceeded", () => {
const { detector, advance } = createDetector()
detector.onTextChunk(20_000)
advance(30_000) // under 60s
detector.isLooping().should.be.false()
})
it("should not trigger when only time threshold is exceeded", () => {
const { detector, advance } = createDetector()
detector.onTextChunk(5_000) // under 15K
advance(90_000)
detector.isLooping().should.be.false()
})
it("should trigger when both thresholds are exceeded", () => {
const { detector, advance } = createDetector()
detector.onTextChunk(16_000)
advance(61_000)
detector.isLooping().should.be.true()
})
it("should accumulate text across multiple chunks", () => {
const { detector, advance } = createDetector()
for (let i = 0; i < 20; i++) {
detector.onTextChunk(1_000) // 20 × 1K = 20K total
}
advance(61_000)
detector.isLooping().should.be.true()
})
it("should reset on tool activity", () => {
const { detector, advance } = createDetector()
detector.onTextChunk(16_000)
advance(61_000)
// Would trigger, but tool activity resets everything
detector.onToolActivity()
detector.isLooping().should.be.false()
})
it("should reset char count on tool activity but re-accumulate after", () => {
const { detector, advance } = createDetector()
detector.onTextChunk(16_000)
advance(30_000)
detector.onToolActivity() // resets both trackers
advance(61_000)
detector.onTextChunk(5_000) // only 5K since reset
detector.isLooping().should.be.false()
})
it("should trigger after tool activity if new text exceeds thresholds", () => {
const { detector, advance } = createDetector()
detector.onTextChunk(16_000)
detector.onToolActivity() // resets
advance(61_000)
detector.onTextChunk(16_000) // new text exceeds threshold
detector.isLooping().should.be.true()
})
it("should work with custom thresholds", () => {
const { detector, advance } = createDetector({
charThreshold: 100,
timeThresholdMs: 1_000,
})
detector.onTextChunk(101)
advance(1_001)
detector.isLooping().should.be.true()
})
it("should reset timer on reasoning activity without clearing char count", () => {
const { detector, advance } = createDetector()
detector.onTextChunk(16_000)
advance(90_000) // 90s of reasoning
detector.onReasoningActivity() // resets timer but keeps 16K chars
advance(30_000) // only 30s since reasoning reset
detector.isLooping().should.be.false()
})
it("should trigger after reasoning if text continues long enough", () => {
const { detector, advance } = createDetector()
advance(90_000) // 90s of reasoning
detector.onReasoningActivity()
detector.onTextChunk(16_000)
advance(61_000) // 61s since reasoning ended
detector.isLooping().should.be.true()
})
it("should not trigger at exact boundary values", () => {
const { detector, advance } = createDetector({
charThreshold: 100,
timeThresholdMs: 1_000,
})
detector.onTextChunk(100) // exactly at, not over
advance(1_000) // exactly at, not over
detector.isLooping().should.be.false()
})
})
+23
View File
@@ -114,6 +114,7 @@ import { Controller } from "../controller"
import { executeHook } from "../hooks/hook-executor"
import { StateManager } from "../storage/StateManager"
import { FocusChainManager } from "./focus-chain"
import { InGenerationLoopDetector } from "./InGenerationLoopDetector"
import { MessageStateHandler } from "./message-state"
import { StreamChunkCoordinator } from "./StreamChunkCoordinator"
import { StreamResponseHandler } from "./StreamResponseHandler"
@@ -2700,6 +2701,7 @@ export class Task {
})
let shouldInterruptStream = false
const loopDetector = new InGenerationLoopDetector()
while (true) {
const chunk = await streamCoordinator.nextChunk()
@@ -2723,6 +2725,8 @@ export class Task {
redacted_data: chunk.redacted_data,
})
loopDetector.onReasoningActivity()
// fixes bug where cancelling task > aborts task > for loop may be in middle of streaming reasoning > say function throws error before we get a chance to properly clean up and cancel the task.
if (!this.taskState.abort) {
const thinkingBlock = reasonsHandler.getCurrentReasoning()
@@ -2754,6 +2758,7 @@ export class Task {
}
await this.processNativeToolCalls(assistantTextOnly, toolUseHandler.getPartialToolUsesAsContent())
loopDetector.onToolActivity()
break
}
case "text": {
@@ -2781,6 +2786,11 @@ export class Task {
if (this.taskState.assistantMessageContent.length > prevLength) {
this.taskState.userMessageContentReady = false // new content we need to present, reset to false in case previous content set this to true
}
loopDetector.onTextChunk(chunk.text.length)
if (this.taskState.assistantMessageContent.some((block) => block.type === "tool_use")) {
loopDetector.onToolActivity()
}
break
}
}
@@ -2818,6 +2828,19 @@ export class Task {
shouldInterruptStream = true
break
}
if (loopDetector.isLooping()) {
const truncated = assistantMessage.slice(0, 500)
assistantMessage =
truncated + "\n\n[Response interrupted: excessive text output without tool use detected]"
assistantTextOnly =
assistantTextOnly.slice(0, 500) +
"\n\n[Response interrupted: excessive text output without tool use detected]"
this.taskState.assistantMessageContent = parseAssistantMessageV2(assistantMessage)
this.api.abort?.()
shouldInterruptStream = true
break
}
}
if (shouldInterruptStream) {