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
19 changes: 19 additions & 0 deletions src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,25 @@ describe("OpenRouterHandler", () => {
)
})

it("passes the request abort signal to the OpenAI SDK", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockCreate = vitest.fn().mockResolvedValue(asyncStreamFrom([]))
Object.defineProperty(OpenAI.prototype, "chat", {
configurable: true,
value: { completions: { create: mockCreate } },
})
const controller = new AbortController()

await collectStream(
handler.createMessage("test", [], { taskId: "test-task", abortSignal: controller.signal }),
)

expect(mockCreate).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ signal: controller.signal }),
)
})

it("adds cache control for supported models", async () => {
const handler = new OpenRouterHandler(
makeApiHandlerOptions({
Expand Down
12 changes: 9 additions & 3 deletions src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,9 +331,15 @@
}

// Add Anthropic beta header for fine-grained tool streaming when using Anthropic models
const requestOptions = modelId.startsWith("anthropic/")
? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }
: undefined
const requestOptions =
modelId.startsWith("anthropic/") || metadata?.abortSignal

Check warning on line 335 in src/api/providers/openrouter.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/openrouter.ts:335: 3 mutation test gaps; example: NoCoverage OptionalChaining mutant (replacement: metadata.abortSignal). See the job summary for the complete list and resolution guidance.
? {
...(modelId.startsWith("anthropic/")

Check warning on line 337 in src/api/providers/openrouter.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/openrouter.ts:337: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }
: {}),
...(metadata?.abortSignal ? { signal: metadata.abortSignal } : {}),
}
: undefined

let stream
try {
Expand Down
56 changes: 56 additions & 0 deletions src/core/task/ReasoningLoopDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const MIN_PATTERN_LENGTH = 80
const MAX_PATTERN_LENGTH = 2_048
const REQUIRED_REPETITIONS = 5
const CHECK_INTERVAL = 64
const BUFFER_LENGTH = MAX_PATTERN_LENGTH * (REQUIRED_REPETITIONS + 1)

Check warning on line 5 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:5: 2 mutation test gaps; example: Survived ArithmeticOperator mutant (replacement: MAX_PATTERN_LENGTH / (REQUIRED_REPETITIONS + 1)). See the job summary for the complete list and resolution guidance.

/** Detects sustained, exact repetition in streamed model reasoning. */
export class ReasoningLoopDetector {
private buffer = ""

Check warning on line 9 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:9: Survived StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.
private uncheckedCharacters = 0

add(text: string): boolean {
if (!text) {

Check warning on line 13 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:13: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return false

Check warning on line 14 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:14: NoCoverage BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}

this.buffer = (this.buffer + text).slice(-BUFFER_LENGTH)

Check warning on line 17 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:17: Survived MethodExpression mutant (replacement: this.buffer + text). See the job summary for the complete list and resolution guidance.
this.uncheckedCharacters += text.length

if (this.uncheckedCharacters < CHECK_INTERVAL) {

Check warning on line 20 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:20: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
return false

Check warning on line 21 in src/core/task/ReasoningLoopDetector.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/ReasoningLoopDetector.ts:21: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}
this.uncheckedCharacters = 0

const maxPatternLength = Math.min(MAX_PATTERN_LENGTH, Math.floor(this.buffer.length / REQUIRED_REPETITIONS))
for (let patternLength = MIN_PATTERN_LENGTH; patternLength <= maxPatternLength; patternLength++) {
const patternStart = this.buffer.length - patternLength
const pattern = this.buffer.slice(patternStart)
let repeats = true

for (let repetition = 2; repetition <= REQUIRED_REPETITIONS; repetition++) {
const start = this.buffer.length - patternLength * repetition
if (this.buffer.slice(start, start + patternLength) !== pattern) {
repeats = false
break
}
}

if (repeats) {
return true
}
}

return false
}
}

export class RepetitiveReasoningError extends Error {
constructor() {
super(
"Repetitive reasoning detected. The model appears to be stuck in a loop, so Zoo Code stopped the request. Retry with less context or a different model.",
)
this.name = "RepetitiveReasoningError"
Object.setPrototypeOf(this, RepetitiveReasoningError.prototype)
}
}
13 changes: 11 additions & 2 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import EventEmitter from "events"

import { AskIgnoredError } from "./AskIgnoredError"
import { RateLimitClock, createRateLimitClock } from "./RateLimitClock"
import { ReasoningLoopDetector, RepetitiveReasoningError } from "./ReasoningLoopDetector"

import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
Expand Down Expand Up @@ -3267,6 +3268,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
})
let assistantMessage = ""
let reasoningMessage = ""
const reasoningLoopDetector = new ReasoningLoopDetector()
const pendingGroundingSources: GroundingSource[] = []
this.isStreaming = true

Expand Down Expand Up @@ -3312,6 +3314,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

switch (chunk.type) {
case "reasoning": {
if (reasoningLoopDetector.add(chunk.text)) {
this.cancelCurrentRequest()
throw new RepetitiveReasoningError()
Comment on lines +3317 to +3319

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Process reasoning before awaiting the next chunk

When a provider emits a reasoning chunk that crosses the repetition threshold and then stalls, the loop awaits nextChunkWithAbort() at line 3308 before invoking this detector. Consequently, the threshold-crossing chunk is not examined and the request remains active until another chunk arrives or the provider times out, defeating the new safeguard in this scenario. Process the current chunk before requesting the next one.

Useful? React with 👍 / 👎.

}
reasoningMessage += chunk.text
// Only apply formatting if the message contains sentence-ending punctuation followed by **
let formattedReasoning = reasoningMessage
Expand Down Expand Up @@ -3719,8 +3725,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// ??= keeps the first reason; a cancel can land during abortStream after cancelReason was already computed.
this.abortReason ??= "user_cancelled"
await this.abortTask()
} else if (error instanceof OutputTokenLimitError) {
// Truncation repeats on an identical request, so never auto-retry it
} else if (
error instanceof OutputTokenLimitError ||
error instanceof RepetitiveReasoningError
) {
// These failures repeat on an identical request, so never auto-retry them
// (even with auto-approval); let the user decide once.
const { response } = await this.ask("api_req_failed", rawErrorMessage)

Expand Down
27 changes: 27 additions & 0 deletions src/core/task/__tests__/ReasoningLoopDetector.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ReasoningLoopDetector } from "../ReasoningLoopDetector"

describe("ReasoningLoopDetector", () => {
it("detects the reported reasoning loop across arbitrary stream chunks", () => {
const detector = new ReasoningLoopDetector()
const cycle = "OK.\n\nHmm. Let me read them.\n\nOK.\n\nHmm. Let me just do it.\n\nLet me read the files.\n\n"
const output = `I will inspect the implementation first.\n${cycle.repeat(8)}`
let detected = false

for (let offset = 0; offset < output.length; offset += 17) {
detected ||= detector.add(output.slice(offset, offset + 17))
}

expect(detected).toBe(true)
})

it("does not flag repeated short phrases or ordinary long reasoning", () => {
const detector = new ReasoningLoopDetector()
const reasoning = Array.from(
{ length: 40 },
(_, index) =>
`Step ${index}: inspect file ${index}, compare its behavior, and record the distinct result. OK.\n`,
).join("")

expect(detector.add(reasoning)).toBe(false)
})
})
39 changes: 39 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,45 @@ describe("Cline", () => {
})
})

describe("repetitive reasoning mid-stream", () => {
it("cancels the request and asks once instead of auto-retrying", async () => {
vi.spyOn(mockProvider, "getState").mockResolvedValue(
providerStateWith({ autoApprovalEnabled: true, requestDelaySeconds: 0 }),
)
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined)
vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo)
vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {})
const askSpy = vi
.spyOn(task, "ask")
.mockResolvedValue({ response: "noButtonClicked" } satisfies TaskAskResult)
const cancelSpy = vi.spyOn(task, "cancelCurrentRequest")
const cycle =
"OK.\n\nHmm. Let me read them.\n\nOK.\n\nHmm. Let me just do it.\n\nLet me read the files.\n\n"
const attemptApiRequestSpy = vi
.spyOn(task, "attemptApiRequest")
.mockImplementation(() =>
asyncStreamFrom<ApiStreamChunk>(
Array.from({ length: 8 }, () => ({ type: "reasoning" as const, text: cycle })),
),
)

await task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }])

expect(cancelSpy).toHaveBeenCalledOnce()
expect(attemptApiRequestSpy).toHaveBeenCalledOnce()
expect(askSpy).toHaveBeenCalledWith(
"api_req_failed",
expect.stringContaining("Repetitive reasoning detected"),
)
})
})

describe("native tool-call request isolation", () => {
it("keeps overlapping Task parser state scoped to each request", async () => {
const firstTask = new Task({
Expand Down
Loading