Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/api/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
// thread stop_reason through so Task.ts can surface it in
// empty-response diagnostics.
finishReason: chunk.delta.stop_reason || undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Only Anthropic populates finishReason, so the diagnostic will show finish_reason: unknown for every other provider. Should base-openai-compatible-provider.ts set it too (it already reads finish_reason), or should the JSDoc note this is Anthropic-only for now?

}

break
Expand Down
8 changes: 8 additions & 0 deletions src/api/transform/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ export interface ApiStreamUsageChunk {
cacheReadTokens?: number
reasoningTokens?: number
totalCost?: number
/**
* finish/stop reason from the provider stream (e.g. "end_turn", "max_tokens")
* when available. Used to diagnose empty-response retries.
* NOTE: only the Anthropic provider populates this field today; the other providers
* leave it unset. The empty-response diagnostic therefore omits the finish_reason
* field when it is unknown rather than printing "unknown".
*/
finishReason?: string
}

export interface ApiStreamGroundingChunk {
Expand Down
107 changes: 104 additions & 3 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ 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
// Hard cap on consecutive empty-assistant-message retries. Before this, the empty-response
// retry loop had NO upper bound and could resend the same unchanged oversized request forever
// at the maximum backoff delay with no give-up condition. We observed this live requiring
// ~4 retries (~7 min) before the provider recovered on its own; 5 gives one margin round
// without letting the loop run indefinitely.
const MAX_EMPTY_RESPONSE_RETRIES = 5 // Maximum consecutive empty-response retries before giving up

export interface TaskOptions extends CreateTaskOptions {
provider: ClineProvider
Expand Down Expand Up @@ -321,6 +327,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
consecutiveMistakeCountForEditFile: Map<string, number> = new Map()
consecutiveNoToolUseCount: number = 0
consecutiveNoAssistantMessagesCount: number = 0
// timestamp (performance.now()) when the empty-assistant-message retry loop started,
// used to surface total elapsed time in the terminal error toast.
emptyResponseRetryLoopStartTimeMs: number = 0
toolUsage: ToolUsage = {}

// Conversation message counts, summarized once per Task Completed
Expand Down Expand Up @@ -2246,6 +2255,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Reset consecutive error counters on abort (manual intervention)
this.consecutiveNoToolUseCount = 0
this.consecutiveNoAssistantMessagesCount = 0
// Also reset the empty-response retry loop timer so a later run starts fresh
this.emptyResponseRetryLoopStartTimeMs = 0

// Force final token usage update before abort event
this.emitFinalTokenUsageUpdate()
Expand Down Expand Up @@ -2772,6 +2783,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true })
let assistantMessage = ""
let reasoningMessage = ""
// finish/stop reason surfaced by the provider stream (when available), included
// in empty-response diagnostics. Only the Anthropic provider populates this
// today, so the diagnostic below omits finish_reason when it is unknown.
let finishReason: string | undefined
const pendingGroundingSources: GroundingSource[] = []
this.isStreaming = true

Expand Down Expand Up @@ -2838,6 +2853,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
cacheReadTokens += chunk.cacheReadTokens ?? 0
totalCost = chunk.totalCost
// capture finish_reason if the provider surfaces it
finishReason = chunk.finishReason ?? finishReason
break
case "grounding":
// Handle grounding sources separately from regular content
Expand Down Expand Up @@ -3423,6 +3440,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (hasTextContent || hasToolUses) {
// Reset counter when we get a successful response with content
this.consecutiveNoAssistantMessagesCount = 0
// Reset the empty-response retry loop timer on success
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})`)
Expand Down Expand Up @@ -3670,14 +3689,72 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

// Enforce a hard cap on consecutive empty responses. 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. Only the Anthropic provider
// surfaces finish_reason today, so omit the field when it is unknown instead
// of printing "unknown" for every other provider.
const finishReasonDetail = finishReason ? `, finish_reason: ${finishReason}` : ""
const emptyResponseDetail =
`(consecutive empty responses: ${this.consecutiveNoAssistantMessagesCount}, ` +
`retryAttempt: ${currentItem.retryAttempt ?? 0}, elapsed: ${emptyResponseElapsedSec}s` +
`${finishReasonDetail})`

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,
})
// Append the Failure marker immediately after the user message so the
// persisted history never ends with a trailing user message, even if an
// abort lands while the user message is being persisted.
await this.addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: "Failure: I repeatedly did not provide a response." }],
})
// Abort check after the history is consistent: if the user hit Stop mid
// give-up, bail before announcing the terminal error.
if (this.abort) {
console.log(
`[Task#${this.taskId}.${this.instanceId}] Task aborted during empty-response give-up; history finalized`,
)
return false
}
await this.say(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If the user presses Stop between the history append above and this call, say() throws on abort and the Failure assistant append below never runs — could that leave the persisted history ending with a user message? An this.abort check after the first await might be worth it.

"error",
`Unexpected API Response: The language model repeatedly returned no response after ` +
`${MAX_EMPTY_RESPONSE_RETRIES} consecutive attempts. ${emptyResponseDetail}`,
)
// Reset the retry counters on the terminal give-up path so a later Stop on
// the same task takes the normal graceful path in cancelTask rather than the
// hard-abort gate.
this.consecutiveNoAssistantMessagesCount = 0
this.emptyResponseRetryLoopStartTimeMs = 0
return false
}

// Check if we should auto-retry or prompt the user
// Reuse the state variable from above
if (state?.autoApprovalEnabled) {
// Auto-retry with backoff - don't persist failure message when retrying
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}`,
),
)

Expand Down Expand Up @@ -3705,7 +3782,7 @@ export class Task extends EventEmitter<TaskEvents> 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") {
Expand Down Expand Up @@ -3735,7 +3812,8 @@ export class Task extends EventEmitter<TaskEvents> 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}`,
)

// Synthetic assistant message recording the failure -- increment
Expand All @@ -3746,6 +3824,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
content: [{ type: "text", text: "Failure: I did not provide a response." }],
})
this.messageCounts.assistant++
// Reset the retry counters on the user-declined terminal path so a later
// Stop on the same task takes the normal graceful path in cancelTask
// rather than the hard-abort gate.
this.consecutiveNoAssistantMessagesCount = 0
this.emptyResponseRetryLoopStartTimeMs = 0
}
}
}
Expand Down Expand Up @@ -4498,6 +4581,24 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
headerText = "Unknown error"
}

// Surface a curated errorDetails summary so a live "Provider Error" toast is
// diagnosable without grepping sidecar logs afterward. The raw array can include
// provider-internal metadata (e.g. google.rpc.RetryInfo with its retryDelay, already
// parsed above for the backoff) that is opaque in a user-facing toast, so filter that
// entry out before serializing the remainder.
const backoffDetailLines: string[] = []
if (Array.isArray(error?.errorDetails) && error.errorDetails.length > 0) {
const sanitizedDetails = error.errorDetails.filter(
(d: { "@type"?: string }) => d?.["@type"] !== "type.googleapis.com/google.rpc.RetryInfo",
)
if (sanitizedDetails.length > 0) {
backoffDetailLines.push(`errorDetails: ${JSON.stringify(sanitizedDetails)}`)
}
}
if (backoffDetailLines.length > 0) {
headerText = `${headerText}\n${backoffDetailLines.join("\n")}`
}

headerText = headerText ? `${headerText}\n` : ""

// Show countdown timer with exponential backoff
Expand Down
49 changes: 49 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,55 @@ describe("Cline", () => {
])
expect(task.messageCounts).toEqual({ user: 1, assistant: 1 })
})

it("gives up after MAX_EMPTY_RESPONSE_RETRIES consecutive empty responses (real retry loop)", async () => {
const task = await createTaskWithManualRetries()

// Drive the REAL request loop: auto-approval is off, so answer the retry prompt
// affirmatively until the hard cap trips. Each attempt surfaces a finish_reason
// (e.g. a model that hit max_tokens) but yields no assistant content.
vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult)
// Call-through spy: records error announcements while still letting the real `say`
// run so the `api_req_started` placeholder is added to clineMessages
// (recursivelyMakeClineRequests updates that placeholder and would otherwise index
// an empty list).
const saySpy = vi.spyOn(task, "say")
vi.spyOn(task, "attemptApiRequest").mockImplementation(() =>
stream([{ type: "usage", inputTokens: 0, outputTokens: 0, finishReason: "max_tokens" }]),
)

const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }])

// Loop exits with a terminal failure (does not hang).
expect(result).toBe(false)

// Retry counters/timer are reset on the terminal give-up path so a later Stop on
// the same task would not hit cancelTask's hard-abort gate.
expect(task.consecutiveNoAssistantMessagesCount).toBe(0)
expect(task.emptyResponseRetryLoopStartTimeMs).toBe(0)

// Repeated empty responses announce the MODEL_NO_ASSISTANT_MESSAGES marker...
const errorCalls = saySpy.mock.calls.filter(([type]) => type === "error")
expect(errorCalls.some(([, text]) => text === "MODEL_NO_ASSISTANT_MESSAGES")).toBe(true)

// ...and the hard cap trips with the terminal give-up error carrying the diagnostic
// detail, including the finish_reason propagated from the (mocked) provider stream.
const giveUpCall = errorCalls.find(([, text]) =>
String(text).startsWith("Unexpected API Response: The language model repeatedly"),
)
expect(giveUpCall).toBeDefined()
expect(String(giveUpCall?.[1])).toContain("consecutive empty responses: 5")
expect(String(giveUpCall?.[1])).toContain("finish_reason: max_tokens")

// History ends with the synthetic Failure message, never a trailing user message.
expect(task.apiConversationHistory).toMatchObject([
{ role: "user", content: [{ type: "text", text: "original user request" }] },
{
role: "assistant",
content: [{ type: "text", text: "Failure: I repeatedly did not provide a response." }],
},
])
})
})

describe("constructor", () => {
Expand Down
18 changes: 18 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3342,6 +3342,24 @@ export class ClineProvider
return
}

// Hard-abort instead of graceful rehydrate when the task is confirmed mid
// empty-response retry loop. 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.
if (task.consecutiveNoAssistantMessagesCount > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This gate has no test coverage anywhere — if the > 0 check were removed, would anything catch it? A cancel-path test may be worth adding (count > 0 → evict, count === 0 → normal graceful path).

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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type MockTask = Partial<Task> &
isStreaming?: boolean
didFinishAbortingStream?: boolean
isWaitingForFirstChunk?: boolean
consecutiveNoAssistantMessagesCount?: number
}
type CreatedHistoryTask = Awaited<ReturnType<ClineProvider["createTaskWithHistoryItem"]>>

Expand Down Expand Up @@ -446,6 +447,38 @@ describe("ClineProvider flicker-free cancel", () => {
expect(mockTask2.emit).toHaveBeenCalledWith("taskFocused")
})

it("hard-aborts (evicts) the current task when it is mid empty-response retry loop", async () => {
seedRegistry(provider, mockTask1)
mockTask1.consecutiveNoAssistantMessagesCount = 3

const evictSpy = vi.spyOn(provider, "evictCurrentTask").mockResolvedValue(undefined)
const cancelInternalSpy = vi
.spyOn(provider as unknown as { cancelTaskInternal: () => Promise<void> }, "cancelTaskInternal")
.mockResolvedValue(undefined)

await provider.cancelTask()

// The hard-abort gate must evict the task and never reach the graceful path.
expect(evictSpy).toHaveBeenCalledTimes(1)
expect(cancelInternalSpy).not.toHaveBeenCalled()
})

it("takes the normal graceful cancel path when not in an empty-response retry loop", async () => {
seedRegistry(provider, mockTask1)
mockTask1.consecutiveNoAssistantMessagesCount = 0

const evictSpy = vi.spyOn(provider, "evictCurrentTask").mockResolvedValue(undefined)
const cancelInternalSpy = vi
.spyOn(provider as unknown as { cancelTaskInternal: () => Promise<void> }, "cancelTaskInternal")
.mockResolvedValue(undefined)

await provider.cancelTask()

// With the counter at 0 the gate must not fire; the graceful path runs.
expect(evictSpy).not.toHaveBeenCalled()
expect(cancelInternalSpy).toHaveBeenCalledTimes(1)
})

it("should remove task from stack when creating different task", async () => {
// Setup: Add a task to the registry first
seedRegistry(provider, mockTask1)
Expand Down
Loading