diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index b55c8b3089..fd7a28382a 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -277,6 +277,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa type: "usage", inputTokens: 0, outputTokens: chunk.usage.output_tokens || 0, + // CLARITY PATCH: thread stop_reason through so Task.ts can surface it in + // empty-response diagnostics (issue-014). + finishReason: chunk.delta.stop_reason || undefined, } break diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 960ebbe770..dd2104c299 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -63,6 +63,11 @@ export interface ApiStreamUsageChunk { cacheReadTokens?: number reasoningTokens?: number totalCost?: number + /** + * CLARITY PATCH: finish/stop reason from the provider stream (e.g. "end_turn", + * "max_tokens") when available. Used to diagnose empty-response retries (issue-014). + */ + finishReason?: string } export interface ApiStreamGroundingChunk { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4ba2996c91..1a2351fe1a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -140,6 +140,13 @@ const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +// CLARITY PATCH: Hard cap on consecutive empty-assistant-message retries (issue-014). +// Before this, the empty-response retry loop had NO upper bound and could resend the +// same oversized request forever at the maximum backoff delay with no give-up condition. +// The 2026-08-03 FACE incident needed 4 retries (~7 min) before the provider recovered +// on its own; 5 gives one margin round without letting the loop run indefinitely. +// See docs/issues/issue-014-empty-response-infinite-retry-loop.md +const MAX_EMPTY_RESPONSE_RETRIES = 5 // Maximum consecutive empty-response retries before giving up export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider @@ -320,6 +327,9 @@ export class Task extends EventEmitter implements TaskLike { consecutiveMistakeCountForEditFile: Map = new Map() consecutiveNoToolUseCount: number = 0 consecutiveNoAssistantMessagesCount: number = 0 + // CLARITY PATCH: timestamp (performance.now()) when the empty-assistant-message retry + // loop started, used to surface total elapsed time in the terminal error toast (issue-014). + emptyResponseRetryLoopStartTimeMs: number = 0 toolUsage: ToolUsage = {} // Checkpoints @@ -2246,6 +2256,8 @@ export class Task extends EventEmitter implements TaskLike { // Reset consecutive error counters on abort (manual intervention) this.consecutiveNoToolUseCount = 0 this.consecutiveNoAssistantMessagesCount = 0 + // CLARITY PATCH: also reset the empty-response retry loop timer (issue-014) + this.emptyResponseRetryLoopStartTimeMs = 0 // Force final token usage update before abort event this.emitFinalTokenUsageUpdate() @@ -2776,6 +2788,9 @@ export class Task extends EventEmitter implements TaskLike { const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true }) let assistantMessage = "" let reasoningMessage = "" + // CLARITY PATCH: finish/stop reason surfaced by the provider stream (when + // available), included in empty-response diagnostics (issue-014). + let finishReason: string | undefined const pendingGroundingSources: GroundingSource[] = [] this.isStreaming = true @@ -2842,6 +2857,8 @@ export class Task extends EventEmitter implements TaskLike { cacheWriteTokens += chunk.cacheWriteTokens ?? 0 cacheReadTokens += chunk.cacheReadTokens ?? 0 totalCost = chunk.totalCost + // CLARITY PATCH: capture finish_reason if the provider surfaces it (issue-014) + finishReason = chunk.finishReason ?? finishReason break case "grounding": // Handle grounding sources separately from regular content @@ -3427,6 +3444,8 @@ export class Task extends EventEmitter implements TaskLike { if (hasTextContent || hasToolUses) { // Reset counter when we get a successful response with content this.consecutiveNoAssistantMessagesCount = 0 + // CLARITY PATCH: reset the empty-response retry loop timer on success (issue-014) + this.emptyResponseRetryLoopStartTimeMs = 0 // Display grounding sources to the user if they exist if (pendingGroundingSources.length > 0) { const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) @@ -3668,6 +3687,49 @@ export class Task extends EventEmitter implements TaskLike { } } + // CLARITY PATCH: Enforce a hard cap on consecutive empty responses (issue-014). + // Previously this loop had NO upper bound — it could retry the same unchanged + // oversized request forever at the maximum backoff delay. Now, after + // MAX_EMPTY_RESPONSE_RETRIES consecutive empty responses we stop retrying and + // surface a terminal error instead of looping indefinitely. + if (this.emptyResponseRetryLoopStartTimeMs === 0) { + this.emptyResponseRetryLoopStartTimeMs = performance.now() + } + const emptyResponseElapsedSec = Math.round( + (performance.now() - this.emptyResponseRetryLoopStartTimeMs) / 1000, + ) + // Diagnostic detail surfaced in the live toast so the failure is diagnosable + // without grepping sidecar logs afterward (the observability gap in issue-014). + const emptyResponseDetail = + `(consecutive empty responses: ${this.consecutiveNoAssistantMessagesCount}, ` + + `retryAttempt: ${currentItem.retryAttempt ?? 0}, elapsed: ${emptyResponseElapsedSec}s, ` + + `finish_reason: ${finishReason ?? "unknown"})` + + if (this.consecutiveNoAssistantMessagesCount >= MAX_EMPTY_RESPONSE_RETRIES) { + // Give up: the user message was already removed above, so re-add it, surface + // a clear terminal error, and end the turn cleanly (mirrors the "user declined + // to retry" terminal path below so the task does not hang). + await this.addToApiConversationHistory({ + role: "user", + content: currentUserContent, + }) + await this.say( + "error", + `Unexpected API Response: The language model repeatedly returned no response after ` + + `${MAX_EMPTY_RESPONSE_RETRIES} consecutive attempts. ${emptyResponseDetail}`, + ) + await this.addToApiConversationHistory({ + role: "assistant", + content: [{ type: "text", text: "Failure: I repeatedly did not provide a response." }], + }) + // Return true (end the loop) rather than false (continue). initiateTaskLoop() + // treats false as "continue" and would start another request with + // formatResponse.noToolsUsed() — which, if also empty, repeats this branch + // without backoff and defeats the retry cap. Returning true exits the outer + // loop so the task ends cleanly after the terminal failure. + return true + } + // Check if we should auto-retry or prompt the user // Reuse the state variable from above if (state?.autoApprovalEnabled) { @@ -3675,7 +3737,8 @@ export class Task extends EventEmitter implements TaskLike { await this.backoffAndAnnounce( currentItem.retryAttempt ?? 0, new Error( - "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", + `Unexpected API Response: The language model did not provide any assistant messages. ` + + `This may indicate an issue with the API or the model's output. ${emptyResponseDetail}`, ), ) @@ -3702,7 +3765,7 @@ export class Task extends EventEmitter implements TaskLike { // Prompt the user for retry decision const { response } = await this.ask( "api_req_failed", - "The model returned no assistant messages. This may indicate an issue with the API or the model's output.", + `The model returned no assistant messages. This may indicate an issue with the API or the model's output. ${emptyResponseDetail}`, ) if (response === "yesButtonClicked") { @@ -3727,7 +3790,8 @@ export class Task extends EventEmitter implements TaskLike { await this.say( "error", - "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", + `Unexpected API Response: The language model did not provide any assistant messages. ` + + `This may indicate an issue with the API or the model's output. ${emptyResponseDetail}`, ) await this.addToApiConversationHistory({ @@ -4489,6 +4553,19 @@ export class Task extends EventEmitter implements TaskLike { headerText = "Unknown error" } + // CLARITY PATCH: surface errorDetails/finish_reason when present so a live "Provider + // Error" toast is diagnosable without grepping sidecar logs afterward (issue-014). + const backoffDetailLines: string[] = [] + if (Array.isArray(error?.errorDetails) && error.errorDetails.length > 0) { + backoffDetailLines.push(`errorDetails: ${JSON.stringify(error.errorDetails)}`) + } + if (error?.finishReason) { + backoffDetailLines.push(`finish_reason: ${error.finishReason}`) + } + if (backoffDetailLines.length > 0) { + headerText = `${headerText}\n${backoffDetailLines.join("\n")}` + } + headerText = headerText ? `${headerText}\n` : "" // Show countdown timer with exponential backoff diff --git a/src/core/task/__tests__/grace-retry-errors.spec.ts b/src/core/task/__tests__/grace-retry-errors.spec.ts index 45c86d92ec..653d4887da 100644 --- a/src/core/task/__tests__/grace-retry-errors.spec.ts +++ b/src/core/task/__tests__/grace-retry-errors.spec.ts @@ -353,6 +353,63 @@ describe("Grace Retry Error Handling", () => { }) }) + describe("Empty-Response Retry Cap (issue-014)", () => { + it("should surface a terminal error once the retry cap is reached", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Simulate reaching the retry cap (MAX_EMPTY_RESPONSE_RETRIES = 5 consecutive + // empty responses). The fix surfaces a terminal error and ends the turn instead + // of looping forever. + task.consecutiveNoAssistantMessagesCount = 5 + + // The retry-cap branch surfaces a terminal error (not the generic + // MODEL_NO_ASSISTANT_MESSAGES marker) once the cap is reached. + await task.say( + "error", + `Unexpected API Response: The language model repeatedly returned no response after ` + + `5 consecutive attempts.`, + ) + + // Verify the terminal error was surfaced. + expect(saySpy).toHaveBeenCalledWith( + "error", + expect.stringContaining("repeatedly returned no response after 5 consecutive attempts"), + ) + }) + + it("should not surface the terminal error before the cap is reached", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + + // Below the cap (e.g. 2 consecutive empty responses), the generic + // MODEL_NO_ASSISTANT_MESSAGES marker is used, not the terminal error. + task.consecutiveNoAssistantMessagesCount = 2 + + if (task.consecutiveNoAssistantMessagesCount >= 2) { + await task.say("error", "MODEL_NO_ASSISTANT_MESSAGES") + } + + expect(saySpy).toHaveBeenCalledWith("error", "MODEL_NO_ASSISTANT_MESSAGES") + expect(saySpy).not.toHaveBeenCalledWith( + "error", + expect.stringContaining("repeatedly returned no response"), + ) + }) + }) + describe("Counter Reset on Success", () => { it("should be able to simulate counter reset when valid content is received", () => { const task = new Task({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 912fed7837..e7df872c5a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3223,6 +3223,24 @@ export class ClineProvider return } + // CLARITY PATCH: hard-abort instead of graceful rehydrate when the task is confirmed + // mid empty-response retry loop (issue-014). The graceful path below cancels the request, + // marks the task "interrupted", and REHYDRATES the same task with its still-oversized + // history — so an unbounded empty-response retry loop would resume immediately after Stop. + // For a task already stuck resending an unchanged retry (consecutiveNoAssistantMessagesCount + // > 0), evict it from the stack entirely (abortTask(true) via removeClineFromStack, no + // rehydrate) so a fresh task/context is required to continue. Normal Stops for tasks NOT + // in this retry loop (counter === 0) are completely unaffected. + // See docs/issues/issue-014-empty-response-infinite-retry-loop.md + if (task.consecutiveNoAssistantMessagesCount > 0) { + this.log( + `[cancelTask] Task ${task.taskId}.${task.instanceId} is mid empty-response retry loop ` + + `(${task.consecutiveNoAssistantMessagesCount} consecutive empty responses); using hard abort instead of rehydrate`, + ) + await this.evictCurrentTask() + return + } + console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) await this.cancelTaskInternal(task) }