Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .changeset/six-guests-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

feat(retry): implement configurable delay and max retries
4 changes: 4 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export const globalSettingsSchema = z.object({
alwaysAllowDelete: z.boolean().optional(), // kilocode_change
writeDelayMs: z.number().min(0).optional(),
alwaysAllowBrowser: z.boolean().optional(),
alwaysApproveResubmit: z.boolean().optional(), // kilocode_change
requestDelaySeconds: z.number().optional(),
requestRetryMax: z.number().min(0).optional(), // kilocode_change
alwaysAllowMcp: z.boolean().optional(),
alwaysAllowModeSwitch: z.boolean().optional(),
alwaysAllowSubtasks: z.boolean().optional(),
Expand Down Expand Up @@ -370,7 +372,9 @@ export const EVALS_SETTINGS: RooCodeSettings = {
alwaysAllowDelete: true, // kilocode_change
writeDelayMs: 1000,
alwaysAllowBrowser: true,
alwaysApproveResubmit: true, // kilocode_change
requestDelaySeconds: 10,
requestRetryMax: 0, // kilocode_change
alwaysAllowMcp: true,
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ export type ExtensionState = Pick<
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
| "alwaysAllowFollowupQuestions"
| "alwaysApproveResubmit" // kilocode_change
| "alwaysAllowExecute"
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
Expand Down Expand Up @@ -568,6 +569,7 @@ export type ExtensionState = Pick<
| "includeCurrentCost"
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "requestRetryMax" // kilocode_change
| "selectedMicrophoneDevice" // kilocode_change: Selected microphone device for STT
> & {
version: string
Expand Down
1 change: 1 addition & 0 deletions src/core/auto-approval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type AutoApprovalState =
| "alwaysAllowSubtasks"
| "alwaysAllowExecute"
| "alwaysAllowFollowupQuestions"
| "alwaysApproveResubmit" // kilocode_change

// Some of these actions have additional settings associated with them.
export type AutoApprovalStateOptions =
Expand Down
35 changes: 20 additions & 15 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,6 @@ import { MessageManager } from "../message-manager"
import { validateAndFixToolResultIds } from "./validateToolResultIds"
import { deduplicateToolUseBlocks } from "./deduplicateToolUseBlocks"

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
Expand Down Expand Up @@ -3595,9 +3594,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
`[Task#${this.taskId}.${this.instanceId}] Stream failed, will retry: ${streamingFailedMessage}`,
)

// Apply exponential backoff similar to first-chunk errors when auto-resubmit is enabled
// Apply backoff similar to first-chunk errors when auto-resubmit is enabled
const stateForBackoff = await this.providerRef.deref()?.getState()
if (stateForBackoff?.autoApprovalEnabled) {
const retryMax = stateForBackoff?.requestRetryMax ?? 0
if (
stateForBackoff?.autoApprovalEnabled &&
stateForBackoff?.alwaysApproveResubmit && // kilocode_change
(retryMax === 0 || (currentItem.retryAttempt ?? 0) < retryMax)
) {

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.

CRITICAL: The retryMax check here only gates the backoff delay — it does NOT gate the actual retry. Lines 3619–3627 (stack.push + continue) execute unconditionally outside this if block, meaning when retryMax is reached, the backoff is skipped but the retry still happens. This creates an infinite retry loop without any delay.

The stack.push + continue block should be moved inside this if, with an else branch that either breaks or prompts the user (similar to the empty-assistant-response path at line 3965 which correctly uses if/else).

await this.backoffAndAnnounce(currentItem.retryAttempt ?? 0, error)

// Check if task was aborted during the backoff
Expand Down Expand Up @@ -3925,7 +3929,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

// Check if we should auto-retry or prompt the user
// Reuse the state variable from above
if (state?.autoApprovalEnabled) {
const retryMax = state?.requestRetryMax ?? 0
if (
state?.autoApprovalEnabled &&
state?.alwaysApproveResubmit && // kilocode_change
(retryMax === 0 || (currentItem.retryAttempt ?? 0) < retryMax)
) {
// Auto-retry with backoff - don't persist failure message when retrying
await this.backoffAndAnnounce(
currentItem.retryAttempt ?? 0,
Expand Down Expand Up @@ -4801,8 +4810,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
// kilocode_change end
// 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.
if (autoApprovalEnabled) {
// Apply shared exponential backoff and countdown UX
const retryMax = state?.requestRetryMax ?? 0 // kilocode_change
if (autoApprovalEnabled && state?.alwaysApproveResubmit && (retryMax === 0 || retryAttempt < retryMax)) {
// Apply shared backoff and countdown UX
await this.backoffAndAnnounce(retryAttempt, error)

// CRITICAL: Check if task was aborted during the backoff countdown
Expand Down Expand Up @@ -4866,16 +4876,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// kilocode_change end
}

// Shared exponential backoff for retries (first-chunk and mid-stream)
// Shared backoff for retries (first-chunk and mid-stream)
private async backoffAndAnnounce(retryAttempt: number, error: any): Promise<void> {

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.

WARNING: The retryAttempt parameter is now unused in this method body. It was previously used for the exponential backoff calculation (Math.pow(2, retryAttempt)), but since the delay is now constant, nothing references it. Consider removing it to avoid confusion.

Also, the comment on line 4925 ("Show countdown timer with exponential backoff") and the error message on line 4938 ("Exponential backoff failed") are stale — the method no longer uses exponential backoff.

try {
const state = await this.providerRef.deref()?.getState()
const baseDelay = state?.requestDelaySeconds || 5

let exponentialDelay = Math.min(
Math.ceil(baseDelay * Math.pow(2, retryAttempt)),
MAX_EXPONENTIAL_BACKOFF_SECONDS,
)
let requestDelaySeconds = state?.requestDelaySeconds ?? 10

// Respect provider rate limit window
let rateLimitDelay = 0
Expand All @@ -4892,11 +4897,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
)
const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/)
if (match) {
exponentialDelay = Number(match[1]) + 1
requestDelaySeconds = Number(match[1]) + 1
}
}

const finalDelay = Math.max(exponentialDelay, rateLimitDelay)
const finalDelay = Math.max(requestDelaySeconds, rateLimitDelay)
if (finalDelay <= 0) {
return
}
Expand Down
125 changes: 125 additions & 0 deletions src/core/task/__tests__/auto-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as os from "os"
import * as path from "path"
import * as vscode from "vscode"
import { Task } from "../Task"

// Mock dependencies
vi.mock("delay", () => ({
__esModule: true,
default: vi.fn().mockResolvedValue(undefined),
}))

vi.mock("p-wait-for", () => ({
default: vi.fn().mockImplementation(async () => Promise.resolve()),
}))

vi.mock("vscode", () => {
return {
workspace: {
getConfiguration: vi.fn(() => ({ get: vi.fn() })),
},
env: {
uriScheme: "vscode",
language: "en",
},
EventEmitter: vi.fn().mockImplementation(() => ({
event: vi.fn(),
fire: vi.fn(),
})),
}
})

describe("Auto-Retry Logic", () => {
let mockProvider: any
let mockApiConfig: any
let mockExtensionContext: any

beforeEach(() => {
mockExtensionContext = {
globalState: {
get: vi.fn(),
update: vi.fn(),
keys: vi.fn().mockReturnValue([]),
},
globalStorageUri: { fsPath: path.join(os.tmpdir(), "test-storage") },
secrets: {
get: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockResolvedValue(undefined),
},
extensionUri: { fsPath: "/mock/path" },
extension: { packageJSON: { version: "1.0.0" } },
}

mockProvider = {
getState: vi.fn().mockResolvedValue({
autoApprovalEnabled: true,
requestDelaySeconds: 1,
requestRetryMax: 3,
}),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
}

mockApiConfig = {
apiProvider: "anthropic",
apiModelId: "claude-3-5-sonnet-20241022",
}
})

it("should calculate correct delay", async () => {
const task = new Task({
context: mockExtensionContext,
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test",
startTask: false,
})

const delay = (task as any).backoffAndAnnounce(1, new Error("test"))

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.

WARNING: Test has no assertions — this test calls backoffAndAnnounce but never awaits the result and contains zero expect() calls. It will always pass regardless of actual behavior.

The comment on line 79 acknowledges this limitation. Consider either:

  1. Properly mocking delay and awaiting the result to verify the delay value
  2. Removing this test until it can be made meaningful (a test with no assertions gives false confidence in coverage)

// We can't easily await this because it has a loop with delay()
// but we can check the internal logic if we expose it or mock delay better
})

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.

WARNING: No-op test — this test creates a Task and calls backoffAndAnnounce but never awaits the returned promise and has no assertions. The comment acknowledges this limitation but the test still passes vacuously, giving false confidence in coverage.

Consider either:

  • Awaiting the promise and asserting on the mocked delay calls
  • Removing this test until it can be properly implemented
  • At minimum, adding await and an assertion on the mock


it("should respect requestRetryMax and alwaysApproveResubmit", async () => {
const state = {
autoApprovalEnabled: true,
alwaysApproveResubmit: true,
requestRetryMax: 2
}

const shouldRetry = (attempt: number) =>

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.

WARNING: Tests re-implement retry logic locally instead of testing the actual Task class — the shouldRetry function defined here duplicates the condition from Task.ts rather than exercising the real code path. If the logic in Task.ts diverges from this local copy, these tests will still pass while the actual behavior is broken.

Consider testing the real Task methods (e.g., verifying that attemptApiRequest or the streaming loop actually stops retrying when requestRetryMax is exceeded) rather than testing a standalone reimplementation of the condition.

state.autoApprovalEnabled &&
state.alwaysApproveResubmit &&
(state.requestRetryMax === 0 || attempt < state.requestRetryMax)

// retryAttempt 0 < 2 -> should retry
expect(shouldRetry(0)).toBe(true)
// retryAttempt 1 < 2 -> should retry
expect(shouldRetry(1)).toBe(true)
// retryAttempt 2 == 2 -> should NOT retry
expect(shouldRetry(2)).toBe(false)

// If alwaysApproveResubmit is false, should NOT retry
state.alwaysApproveResubmit = false
expect(shouldRetry(0)).toBe(false)
})

it("should handle unlimited retries when requestRetryMax is 0", async () => {
const state = {
autoApprovalEnabled: true,
alwaysApproveResubmit: true,
requestRetryMax: 0
}

const shouldRetry = (attempt: number) =>
state.autoApprovalEnabled &&
state.alwaysApproveResubmit &&
(state.requestRetryMax === 0 || attempt < state.requestRetryMax)

expect(shouldRetry(100)).toBe(true)

// If alwaysApproveResubmit is false, should NOT retry even with unlimited retries
state.alwaysApproveResubmit = false
expect(shouldRetry(100)).toBe(false)
})
})
8 changes: 8 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2251,6 +2251,7 @@ export class ClineProvider
enhancementApiConfigId,
commitMessageApiConfigId, // kilocode_change
terminalCommandApiConfigId, // kilocode_change
requestRetryMax, // kilocode_change
autoApprovalEnabled,
customModes,
experiments,
Expand Down Expand Up @@ -2291,6 +2292,7 @@ export class ClineProvider
dismissedNotificationIds, // kilocode_change
morphApiKey, // kilocode_change
fastApplyModel, // kilocode_change: Fast Apply model selection
alwaysApproveResubmit, // kilocode_change
fastApplyApiProvider, // kilocode_change: Fast Apply model api base url
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs,
Expand Down Expand Up @@ -2380,6 +2382,7 @@ export class ClineProvider
alwaysAllowMcp: alwaysAllowMcp ?? false,
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
alwaysApproveResubmit: alwaysApproveResubmit ?? true, // kilocode_change
isBrowserSessionActive,
yoloMode: yoloMode ?? false, // kilocode_change
allowedMaxRequests,
Expand Down Expand Up @@ -2527,6 +2530,8 @@ export class ClineProvider
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
requestDelaySeconds: requestDelaySeconds ?? 10, // kilocode_change
requestRetryMax: requestRetryMax ?? 0, // kilocode_change
taskSyncEnabled,
remoteControlEnabled,
imageGenerationProvider,
Expand Down Expand Up @@ -2703,6 +2708,9 @@ export class ClineProvider
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? true,
alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? true,
alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false,
alwaysApproveResubmit: stateValues.alwaysApproveResubmit ?? true, // kilocode_change
requestDelaySeconds: stateValues.requestDelaySeconds ?? 10, // kilocode_change
requestRetryMax: stateValues.requestRetryMax ?? 0, // kilocode_change
isBrowserSessionActive,
yoloMode: stateValues.yoloMode ?? false, // kilocode_change
followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000,
Expand Down
6 changes: 6 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,12 @@ export const webviewMessageHandler = async (
if (!value) {
continue
}
} else if (
key === "alwaysApproveResubmit" ||
key === "requestRetryMax" ||
key === "requestDelaySeconds"
) {
newValue = value
}

await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue)
Expand Down
Loading