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,
// CLARITY PATCH: thread stop_reason through so Task.ts can surface it in
// empty-response diagnostics (issue-014).
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
5 changes: 5 additions & 0 deletions src/api/transform/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
83 changes: 80 additions & 3 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

docs/issues/issue-014-empty-response-infinite-retry-loop.md does not exist — can you just reference the issue and maybe include a comment on the issue.

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 @@ -320,6 +327,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
consecutiveMistakeCountForEditFile: Map<string, number> = 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
Expand Down Expand Up @@ -2246,6 +2256,8 @@ export class Task extends EventEmitter<TaskEvents> 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()
Expand Down Expand Up @@ -2776,6 +2788,9 @@ export class Task extends EventEmitter<TaskEvents> 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

Expand Down Expand Up @@ -2842,6 +2857,8 @@ export class Task extends EventEmitter<TaskEvents> 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
Expand Down Expand Up @@ -3427,6 +3444,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
// 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})`)
Expand Down Expand Up @@ -3668,14 +3687,58 @@ export class Task extends EventEmitter<TaskEvents> 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(

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}`,
)
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

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.

Should consecutiveNoAssistantMessagesCount and emptyResponseRetryLoopStartTimeMs be reset here, like the success path does (~3446)? Left at 5, a later Stop on this task would hit the hard-abort gate in cancelTask even though the loop already ended.

}

// 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 All @@ -3702,7 +3765,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 All @@ -3727,7 +3790,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}`,
)

await this.addToApiConversationHistory({
Expand Down Expand Up @@ -4489,6 +4553,19 @@ export class Task extends EventEmitter<TaskEvents> 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)}`)

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.

For a 429 with Google RPC details, would this stringify raw RetryInfo metadata into the toast? errorDetails is parsed as RetryInfo at line 4528 for delay extraction. Filtering those entries out before display might be cleaner.

}
if (error?.finishReason) {

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.

Does anything attach finishReason to an error object? I could not find it — handleProviderError does not preserve it and all three call sites pass plain errors, so this branch never fires (the value already rides in emptyResponseDetail). Drop it?

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
Expand Down
57 changes: 57 additions & 0 deletions src/core/task/__tests__/grace-retry-errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Comment on lines +365 to +409

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/core/task/__tests__/grace-retry-errors.spec.ts --items all
rg -n -C 8 'MODEL_NO_ASSISTANT_MESSAGES|MAX_EMPTY_RESPONSE_RETRIES|consecutiveNoAssistantMessagesCount|repeatedly returned no response' \
  src/core/task/Task.ts src/core/task/__tests__/grace-retry-errors.spec.ts

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 34804


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant Task implementation and retry-path structure.
sed -n '3620,3750p' src/core/task/Task.ts
printf '\n--- surrounding loop/control flow ---\n'
rg -n -C 5 'for \\(const .*RetryItem|retryAttempt|currentItem|while\\(|for \\(|continue|break' src/core/task/Task.ts | sed -n '1,220p'

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 6283


Exercise the Task retry path instead of calling the spy directly.

Both tests set consecutiveNoAssistantMessagesCount, then invoke the mocked task.say() directly. The second test also implements a local retry condition. These pass even if Task stops enforcing the retry cap, return termination, or persisted failure message. Drive consecutive empty provider responses through the Task loop and assert the terminal error, loop termination, and persisted failure message after attempt 5.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/task/__tests__/grace-retry-errors.spec.ts` around lines 365 - 409,
Rewrite both tests to exercise the Task retry flow with consecutive empty
provider responses rather than directly invoking the mocked task.say method or
duplicating the retry condition. Configure the mock provider and Task execution
so five empty responses pass through the real loop, then assert that the fifth
attempt emits the terminal retry-cap error, execution terminates, and the
failure message is persisted. Add the corresponding below-cap assertion using
the real flow, confirming the generic MODEL_NO_ASSISTANT_MESSAGES behavior
remains in place before the cap.

})
Comment on lines +357 to +410

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.

Both tests call task.say() themselves and then assert the spy saw the call the test just made, so the production cap branch (Task.ts:3708) is never executed. Would these tests still pass if the cap branch were deleted? It might be better to drive the request loop with a mocked stream that yields empty responses (like the attemptApiRequest harness in Task.spec.ts) and assert the terminal message, the Failure history entry, and the loop exit — that could also cover finishReason propagation and the timer in one go.

})

describe("Counter Reset on Success", () => {
it("should be able to simulate counter reset when valid content is received", () => {
const task = new Task({
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 @@ -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) {

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
Loading