From 280b24009063c31e6dce160542a0eb145d3f971f Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 4 Oct 2026 15:24:28 +0000 Subject: [PATCH] fix: stop repetitive reasoning streams Amp-Thread-ID: https://ampcode.com/threads/T-01a1077e-5823-744c-ad1a-1653a3ccdac5 --- .../providers/__tests__/openrouter.spec.ts | 19 +++++++ src/api/providers/openrouter.ts | 12 +++- src/core/task/ReasoningLoopDetector.ts | 56 +++++++++++++++++++ src/core/task/Task.ts | 13 ++++- .../__tests__/ReasoningLoopDetector.spec.ts | 27 +++++++++ src/core/task/__tests__/Task.spec.ts | 39 +++++++++++++ 6 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 src/core/task/ReasoningLoopDetector.ts create mode 100644 src/core/task/__tests__/ReasoningLoopDetector.spec.ts diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 27a84363f6..b12a0d1132 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -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({ diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index ed53c111b5..9c198a32a4 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -331,9 +331,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // 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 + ? { + ...(modelId.startsWith("anthropic/") + ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } + : {}), + ...(metadata?.abortSignal ? { signal: metadata.abortSignal } : {}), + } + : undefined let stream try { diff --git a/src/core/task/ReasoningLoopDetector.ts b/src/core/task/ReasoningLoopDetector.ts new file mode 100644 index 0000000000..e479d62689 --- /dev/null +++ b/src/core/task/ReasoningLoopDetector.ts @@ -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) + +/** Detects sustained, exact repetition in streamed model reasoning. */ +export class ReasoningLoopDetector { + private buffer = "" + private uncheckedCharacters = 0 + + add(text: string): boolean { + if (!text) { + return false + } + + this.buffer = (this.buffer + text).slice(-BUFFER_LENGTH) + this.uncheckedCharacters += text.length + + if (this.uncheckedCharacters < CHECK_INTERVAL) { + return false + } + 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) + } +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d5313f68cf..af51d36ebe 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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" @@ -3267,6 +3268,7 @@ export class Task extends EventEmitter implements TaskLike { }) let assistantMessage = "" let reasoningMessage = "" + const reasoningLoopDetector = new ReasoningLoopDetector() const pendingGroundingSources: GroundingSource[] = [] this.isStreaming = true @@ -3312,6 +3314,10 @@ export class Task extends EventEmitter implements TaskLike { switch (chunk.type) { case "reasoning": { + if (reasoningLoopDetector.add(chunk.text)) { + this.cancelCurrentRequest() + throw new RepetitiveReasoningError() + } reasoningMessage += chunk.text // Only apply formatting if the message contains sentence-ending punctuation followed by ** let formattedReasoning = reasoningMessage @@ -3719,8 +3725,11 @@ export class Task extends EventEmitter 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) diff --git a/src/core/task/__tests__/ReasoningLoopDetector.spec.ts b/src/core/task/__tests__/ReasoningLoopDetector.spec.ts new file mode 100644 index 0000000000..3697f1bbee --- /dev/null +++ b/src/core/task/__tests__/ReasoningLoopDetector.spec.ts @@ -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) + }) +}) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index eae85cf9f2..fe30eaefcc 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -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( + 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({