diff --git a/.changeset/slow-ladybugs-invite.md b/.changeset/slow-ladybugs-invite.md new file mode 100644 index 0000000..eca059e --- /dev/null +++ b/.changeset/slow-ladybugs-invite.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Automatically retry failed API requests with a configurable delay (thanks @RaySinner!) diff --git a/README.md b/README.md index bf2a627..d067902 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f - Per-tool MCP auto-approval - Enable/disable individual MCP servers - Enable/disable the MCP feature overall +- Automatically retry failed API requests with a configurable delay - Configurable delay after auto-writes to allow diagnostics to detect potential problems - Control the number of terminal output lines to pass to the model when executing commands - Runs alongside the original Cline diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 84bf47c..e1ba0c1 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -766,7 +766,7 @@ export class Cline { async *attemptApiRequest(previousApiReqIndex: number): ApiStream { let mcpHub: McpHub | undefined - const { mcpEnabled } = await this.providerRef.deref()?.getState() ?? {} + const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds } = await this.providerRef.deref()?.getState() ?? {} if (mcpEnabled ?? true) { mcpHub = this.providerRef.deref()?.mcpHub @@ -810,18 +810,42 @@ export class Cline { yield firstChunk.value } catch (error) { // 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. - const { response } = await this.ask( - "api_req_failed", - error.message ?? JSON.stringify(serializeError(error), null, 2), - ) - if (response !== "yesButtonClicked") { - // this will never happen since if noButtonClicked, we will clear current task, aborting this instance - throw new Error("API request failed") + if (alwaysApproveResubmit) { + const requestDelay = requestDelaySeconds || 5 + // Automatically retry with delay + await this.say( + "error", + `Error (${ + error.message?.toLowerCase().includes("429") || + error.message?.toLowerCase().includes("rate limit") || + error.message?.toLowerCase().includes("too many requests") || + error.message?.toLowerCase().includes("throttled") + ? "rate limit" + : error.message?.includes("500") || error.message?.includes("503") + ? "internal server error" + : "unknown" + }). ↺ Retrying in ${requestDelay} seconds...`, + ) + await this.say("api_req_retry_delayed") + await delay(requestDelay * 1000) + await this.say("api_req_retried") + // delegate generator output from the recursive call + yield* this.attemptApiRequest(previousApiReqIndex) + return + } else { + const { response } = await this.ask( + "api_req_failed", + error.message ?? JSON.stringify(serializeError(error), null, 2), + ) + if (response !== "yesButtonClicked") { + // this will never happen since if noButtonClicked, we will clear current task, aborting this instance + throw new Error("API request failed") + } + await this.say("api_req_retried") + // delegate generator output from the recursive call + yield* this.attemptApiRequest(previousApiReqIndex) + return } - await this.say("api_req_retried") - // delegate generator output from the recursive call - yield* this.attemptApiRequest(previousApiReqIndex) - return } // no error, so we can continue to yield all remaining chunks diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c6b1eab..579a12e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -83,6 +83,8 @@ type GlobalStateKey = | "writeDelayMs" | "terminalOutputLineLimit" | "mcpEnabled" + | "alwaysApproveResubmit" + | "requestDelaySeconds" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", uiMessages: "ui_messages.json", @@ -675,6 +677,14 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("fuzzyMatchThreshold", message.value) await this.postStateToWebview() break + case "alwaysApproveResubmit": + await this.updateGlobalState("alwaysApproveResubmit", message.bool ?? false) + await this.postStateToWebview() + break + case "requestDelaySeconds": + await this.updateGlobalState("requestDelaySeconds", message.value ?? 5) + await this.postStateToWebview() + break case "preferredLanguage": await this.updateGlobalState("preferredLanguage", message.text) await this.postStateToWebview() @@ -1224,9 +1234,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async getStateToPostToWebview() { - const { - apiConfiguration, - lastShownAnnouncementId, + const { + apiConfiguration, + lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, alwaysAllowWrite, @@ -1244,6 +1254,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { terminalOutputLineLimit, fuzzyMatchThreshold, mcpEnabled, + alwaysApproveResubmit, + requestDelaySeconds, } = await this.getState() const allowedCommands = vscode.workspace @@ -1276,6 +1288,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { terminalOutputLineLimit: terminalOutputLineLimit ?? 500, fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0, mcpEnabled: mcpEnabled ?? true, + alwaysApproveResubmit: alwaysApproveResubmit ?? false, + requestDelaySeconds: requestDelaySeconds ?? 5, } } @@ -1381,6 +1395,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { screenshotQuality, terminalOutputLineLimit, mcpEnabled, + alwaysApproveResubmit, + requestDelaySeconds, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1431,6 +1447,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("screenshotQuality") as Promise, this.getGlobalState("terminalOutputLineLimit") as Promise, this.getGlobalState("mcpEnabled") as Promise, + this.getGlobalState("alwaysApproveResubmit") as Promise, + this.getGlobalState("requestDelaySeconds") as Promise, ]) let apiProvider: ApiProvider @@ -1525,6 +1543,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { return langMap[vscodeLang.split('-')[0]] ?? 'English'; })(), mcpEnabled: mcpEnabled ?? true, + alwaysApproveResubmit: alwaysApproveResubmit ?? false, + requestDelaySeconds: requestDelaySeconds ?? 5, } } diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 6283ba5..0a7ee9a 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -263,6 +263,7 @@ describe('ClineProvider', () => { browserViewportSize: "900x600", fuzzyMatchThreshold: 1.0, mcpEnabled: true, + requestDelaySeconds: 5 } const message: ExtensionMessage = { @@ -382,6 +383,42 @@ describe('ClineProvider', () => { expect(mockPostMessage).toHaveBeenCalled() }) + test('requestDelaySeconds defaults to 5 seconds', async () => { + // Mock globalState.get to return undefined for requestDelaySeconds + (mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { + if (key === 'requestDelaySeconds') { + return undefined + } + return null + }) + + const state = await provider.getState() + expect(state.requestDelaySeconds).toBe(5) + }) + + test('alwaysApproveResubmit defaults to false', async () => { + // Mock globalState.get to return undefined for alwaysApproveResubmit + (mockContext.globalState.get as jest.Mock).mockReturnValue(undefined) + + const state = await provider.getState() + expect(state.alwaysApproveResubmit).toBe(false) + }) + + test('handles request delay settings messages', async () => { + provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Test alwaysApproveResubmit + await messageHandler({ type: 'alwaysApproveResubmit', bool: true }) + expect(mockContext.globalState.update).toHaveBeenCalledWith('alwaysApproveResubmit', true) + expect(mockPostMessage).toHaveBeenCalled() + + // Test requestDelaySeconds + await messageHandler({ type: 'requestDelaySeconds', value: 10 }) + expect(mockContext.globalState.update).toHaveBeenCalledWith('requestDelaySeconds', 10) + expect(mockPostMessage).toHaveBeenCalled() + }) + test('file content includes line numbers', async () => { const { extractTextFromFile } = require('../../../integrations/misc/extract-text') const result = await extractTextFromFile('test.js') diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 30df793..6b877a0 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -56,6 +56,8 @@ export interface ExtensionState { alwaysAllowExecute?: boolean alwaysAllowBrowser?: boolean alwaysAllowMcp?: boolean + alwaysApproveResubmit?: boolean + requestDelaySeconds: number uriScheme?: string allowedCommands?: string[] soundEnabled?: boolean @@ -103,6 +105,7 @@ export type ClineSay = | "user_feedback" | "user_feedback_diff" | "api_req_retried" + | "api_req_retry_delayed" | "command_output" | "tool" | "shell_integration_warning" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 48eeb4a..0ca7cb3 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -52,6 +52,8 @@ export interface WebviewMessage { | "terminalOutputLineLimit" | "mcpEnabled" | "searchCommits" + | "alwaysApproveResubmit" + | "requestDelaySeconds" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5ae0858..956b76b 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -51,6 +51,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { terminalOutputLineLimit, setTerminalOutputLineLimit, mcpEnabled, + alwaysApproveResubmit, + setAlwaysApproveResubmit, + requestDelaySeconds, + setRequestDelaySeconds, } = useExtensionState() const [apiErrorMessage, setApiErrorMessage] = useState(undefined) const [modelIdErrorMessage, setModelIdErrorMessage] = useState(undefined) @@ -83,6 +87,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 }) vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 }) vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) + vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) + vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) onDone() } } @@ -355,11 +361,47 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { Always approve browser actions

- Automatically perform browser actions without requiring approval
+ Automatically perform browser actions without requiring approval
Note: Only applies when the model supports computer use

+
+ setAlwaysApproveResubmit(e.target.checked)}> + Always retry failed API requests + +

+ Automatically retry failed API requests when server returns an error response +

+ {alwaysApproveResubmit && ( +
+
+ setRequestDelaySeconds(parseInt(e.target.value))} + style={{ + flex: 1, + accentColor: 'var(--vscode-button-background)', + height: '2px' + }} + /> + + {requestDelaySeconds}s + +
+

+ Delay before retrying the request +

+
+ )} +
+
{
-

Notification Settings

+

Notification Settings

setSoundEnabled(e.target.checked)}> Enable sound effects diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 52ee1e4..131364b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -46,6 +46,10 @@ export interface ExtensionStateContextType extends ExtensionState { setTerminalOutputLineLimit: (value: number) => void mcpEnabled: boolean setMcpEnabled: (value: boolean) => void + alwaysApproveResubmit?: boolean + setAlwaysApproveResubmit: (value: boolean) => void + requestDelaySeconds: number + setRequestDelaySeconds: (value: number) => void } export const ExtensionStateContext = createContext(undefined) @@ -67,6 +71,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode screenshotQuality: 75, terminalOutputLineLimit: 500, mcpEnabled: true, + alwaysApproveResubmit: false, + requestDelaySeconds: 5 }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -201,6 +207,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })), setTerminalOutputLineLimit: (value) => setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), + setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), + setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })) } return {children}