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
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 @@ -79,7 +79,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().optional(), // kilocode_change
alwaysAllowMcp: z.boolean().optional(),
alwaysAllowModeSwitch: z.boolean().optional(),
alwaysAllowSubtasks: z.boolean().optional(),
Expand Down Expand Up @@ -362,7 +364,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
1 change: 1 addition & 0 deletions src/core/auto-approval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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 @@ -146,7 +146,6 @@ import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
import { MessageManager } from "../message-manager"
import { validateAndFixToolResultIds } from "./validateToolResultIds"

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 @@ -3397,9 +3396,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)
) {
await this.backoffAndAnnounce(currentItem.retryAttempt ?? 0, error)

// Check if task was aborted during the backoff
Expand Down Expand Up @@ -3680,7 +3684,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 @@ -4433,8 +4442,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 @@ -4494,16 +4504,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

// 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> {
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 @@ -4520,11 +4525,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"))
// 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
})

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

const shouldRetry = (attempt: number) =>
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 @@ -2126,6 +2126,7 @@ export class ClineProvider
enhancementApiConfigId,
commitMessageApiConfigId, // kilocode_change
terminalCommandApiConfigId, // kilocode_change
requestRetryMax, // kilocode_change
autoApprovalEnabled,
customModes,
experiments,
Expand Down Expand Up @@ -2166,6 +2167,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 @@ -2255,6 +2257,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 @@ -2401,6 +2404,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 @@ -2569,6 +2574,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 @@ -673,6 +673,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
2 changes: 2 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ export type ExtensionState = Pick<
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
| "alwaysAllowFollowupQuestions"
| "alwaysApproveResubmit" // kilocode_change
| "alwaysAllowExecute"
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
Expand Down Expand Up @@ -467,6 +468,7 @@ export type ExtensionState = Pick<
| "includeCurrentCost"
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "requestRetryMax" // kilocode_change
| "selectedMicrophoneDevice" // kilocode_change: Selected microphone device for STT
> & {
version: string
Expand Down
Loading