Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit c43edde

Browse files
committed
fix: add stream retry cap and context recovery hint for provider errors (#12087)
- Add MAX_STREAM_RETRIES (5) to cap first-chunk and mid-stream error retries, preventing indefinite retry loops when auto-approval is enabled - Add context recovery hint prepended to user content on retry attempts, helping weaker models re-orient to the current task instead of hallucinating about previously completed tasks - When max retries are exceeded, present the error to the user for manual retry instead of continuing to auto-retry indefinitely - Update last user message in API history on retry with recovery hint and refreshed environment details
1 parent 7adbfec commit c43edde

2 files changed

Lines changed: 357 additions & 3 deletions

File tree

src/core/task/Task.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
137137
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
138138
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
139139
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
140+
const MAX_STREAM_RETRIES = 5 // Maximum retries for first-chunk and mid-stream errors
140141

141142
export interface TaskOptions extends CreateTaskOptions {
142143
provider: ClineProvider
@@ -2646,6 +2647,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26462647
// Add environment details as its own text block, separate from tool
26472648
// results.
26482649
let finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }]
2650+
2651+
// When retrying after an error, prepend a context recovery hint to help the model
2652+
// re-orient to the current task. This prevents weaker models from latching onto
2653+
// earlier completed tasks instead of the user's most recent request.
2654+
const currentRetryAttempt = currentItem.retryAttempt ?? 0
2655+
if (currentRetryAttempt > 0) {
2656+
const recoveryHint: Anthropic.Messages.TextBlockParam = {
2657+
type: "text" as const,
2658+
text: "[CONTEXT RECOVERY NOTE: The previous API request failed due to a provider error and was automatically retried. Please focus on the user's most recent request below and continue from where you left off. Do not repeat or re-announce previously completed tasks.]",
2659+
}
2660+
finalUserContent = [recoveryHint, ...finalUserContent]
2661+
}
2662+
26492663
// Only add user message to conversation history if:
26502664
// 1. This is the first attempt (retryAttempt === 0), AND
26512665
// 2. The original userContent was not empty (empty signals delegation resume where
@@ -2660,6 +2674,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26602674
TelemetryService.instance.captureConversationMessage(this.taskId, "user")
26612675
}
26622676

2677+
// On retry, update the existing last user message in API history with the
2678+
// recovery hint and refreshed environment details.
2679+
if (currentRetryAttempt > 0 && !isEmptyUserContent) {
2680+
const lastIdx = this.apiConversationHistory.length - 1
2681+
if (lastIdx >= 0 && this.apiConversationHistory[lastIdx].role === "user") {
2682+
this.apiConversationHistory[lastIdx] = { role: "user", content: finalUserContent }
2683+
}
2684+
}
2685+
26632686
// Since we sent off a placeholder api_req_started message to update the
26642687
// webview while waiting to actually start the API request (to load
26652688
// potential details for example), we need to update the text of that
@@ -3264,14 +3287,37 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
32643287
} else {
32653288
// Stream failed - log the error and retry with the same content
32663289
// The existing rate limiting will prevent rapid retries
3290+
const currentRetry = currentItem.retryAttempt ?? 0
32673291
console.error(
3268-
`[Task#${this.taskId}.${this.instanceId}] Stream failed, will retry: ${streamingFailedMessage}`,
3292+
`[Task#${this.taskId}.${this.instanceId}] Stream failed (attempt ${currentRetry + 1}/${MAX_STREAM_RETRIES}), will retry: ${streamingFailedMessage}`,
32693293
)
32703294

3295+
// Check if we've exceeded the maximum number of stream retries
3296+
if (currentRetry >= MAX_STREAM_RETRIES) {
3297+
console.error(
3298+
`[Task#${this.taskId}.${this.instanceId}] Max mid-stream retries (${MAX_STREAM_RETRIES}) exceeded, presenting error to user`,
3299+
)
3300+
const { response } = await this.ask(
3301+
"api_req_failed",
3302+
`Mid-stream error after ${MAX_STREAM_RETRIES} retries: ${streamingFailedMessage}`,
3303+
)
3304+
if (response !== "yesButtonClicked") {
3305+
break
3306+
}
3307+
await this.say("api_req_retried")
3308+
// User clicked retry - reset retry count and continue
3309+
stack.push({
3310+
userContent: currentUserContent,
3311+
includeFileDetails: false,
3312+
retryAttempt: 0,
3313+
})
3314+
continue
3315+
}
3316+
32713317
// Apply exponential backoff similar to first-chunk errors when auto-resubmit is enabled
32723318
const stateForBackoff = await this.providerRef.deref()?.getState()
32733319
if (stateForBackoff?.autoApprovalEnabled) {
3274-
await this.backoffAndAnnounce(currentItem.retryAttempt ?? 0, error)
3320+
await this.backoffAndAnnounce(currentRetry, error)
32753321

32763322
// Check if task was aborted during the backoff
32773323
if (this.abort) {
@@ -3289,7 +3335,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
32893335
stack.push({
32903336
userContent: currentUserContent,
32913337
includeFileDetails: false,
3292-
retryAttempt: (currentItem.retryAttempt ?? 0) + 1,
3338+
retryAttempt: currentRetry + 1,
32933339
})
32943340

32953341
// Continue to retry the request
@@ -4327,6 +4373,24 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
43274373

43284374
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
43294375
if (autoApprovalEnabled) {
4376+
// Check if we've exceeded the maximum number of stream retries
4377+
if (retryAttempt >= MAX_STREAM_RETRIES) {
4378+
console.error(
4379+
`[Task#${this.taskId}.${this.instanceId}] Max first-chunk retries (${MAX_STREAM_RETRIES}) exceeded, presenting error to user`,
4380+
)
4381+
const { response } = await this.ask(
4382+
"api_req_failed",
4383+
error.message ?? JSON.stringify(serializeError(error), null, 2),
4384+
)
4385+
if (response !== "yesButtonClicked") {
4386+
throw new Error("API request failed")
4387+
}
4388+
await this.say("api_req_retried")
4389+
// User clicked retry - reset retry count
4390+
yield* this.attemptApiRequest(0)
4391+
return
4392+
}
4393+
43304394
// Apply shared exponential backoff and countdown UX
43314395
await this.backoffAndAnnounce(retryAttempt, error)
43324396

0 commit comments

Comments
 (0)