diff --git a/.changeset/tame-walls-kiss.md b/.changeset/tame-walls-kiss.md
new file mode 100644
index 0000000..1cebabb
--- /dev/null
+++ b/.changeset/tame-walls-kiss.md
@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Use an exponential backoff for API retries
diff --git a/.eslintrc.json b/.eslintrc.json
index 3876c59..80eb793 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,56 +1,24 @@
{
"root": true,
- "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
- "ecmaVersion": 2021,
- "sourceType": "module",
- "project": "./tsconfig.json"
+ "ecmaVersion": 6,
+ "sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {
- "@typescript-eslint/naming-convention": ["warn"],
- "@typescript-eslint/no-explicit-any": "off",
- "@typescript-eslint/no-unused-vars": [
+ "@typescript-eslint/naming-convention": [
"warn",
{
- "argsIgnorePattern": "^_",
- "varsIgnorePattern": "^_",
- "caughtErrorsIgnorePattern": "^_"
+ "selector": "import",
+ "format": ["camelCase", "PascalCase"]
}
],
- "@typescript-eslint/explicit-function-return-type": [
- "warn",
- {
- "allowExpressions": true,
- "allowTypedFunctionExpressions": true
- }
- ],
- "@typescript-eslint/explicit-member-accessibility": [
- "warn",
- {
- "accessibility": "explicit"
- }
- ],
- "@typescript-eslint/no-non-null-assertion": "warn",
+ "@typescript-eslint/semi": "off",
+ "eqeqeq": "warn",
"no-throw-literal": "warn",
- "semi": ["off", "always"],
- "quotes": ["warn", "double", { "avoidEscape": true }],
- "@typescript-eslint/ban-types": "off",
- "@typescript-eslint/no-var-requires": "warn",
- "no-extra-semi": "warn",
- "prefer-const": "warn",
- "no-mixed-spaces-and-tabs": "warn",
- "no-case-declarations": "warn",
- "no-useless-escape": "warn",
- "require-yield": "warn",
- "no-empty": "warn",
- "no-control-regex": "warn",
- "@typescript-eslint/ban-ts-comment": "warn"
+ "semi": "off",
+ "react-hooks/exhaustive-deps": "off"
},
- "env": {
- "node": true,
- "es2021": true
- },
- "ignorePatterns": ["dist/**", "out/**", "webview-ui/**", "**/*.js"]
+ "ignorePatterns": ["out", "dist", "**/*.d.ts"]
}
diff --git a/package.json b/package.json
index f115bc6..fa3b84f 100644
--- a/package.json
+++ b/package.json
@@ -214,7 +214,7 @@
"compile": "npm run check-types && npm run lint && node esbuild.js",
"compile-tests": "tsc -p . --outDir out",
"install:all": "npm install && cd webview-ui && npm install",
- "lint": "eslint src --ext ts --quiet && npm run lint --prefix webview-ui",
+ "lint": "eslint src --ext ts && npm run lint --prefix webview-ui",
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
"start:webview": "cd webview-ui && npm run start",
diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts
index b6f15d2..43ed56c 100644
--- a/src/api/providers/openrouter.ts
+++ b/src/api/providers/openrouter.ts
@@ -118,8 +118,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
// Handle models based on deepseek-r1
if (
- this.getModel().id === "deepseek/deepseek-r1" ||
- this.getModel().id.startsWith("deepseek/deepseek-r1:") ||
+ this.getModel().id.startsWith("deepseek/deepseek-r1") ||
this.getModel().id === "perplexity/sonar-reasoning"
) {
// Recommended temperature for DeepSeek reasoning models
diff --git a/src/core/Cline.ts b/src/core/Cline.ts
index e291197..0c0bd37 100644
--- a/src/core/Cline.ts
+++ b/src/core/Cline.ts
@@ -793,7 +793,7 @@ export class Cline {
}
}
- async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
+ async *attemptApiRequest(previousApiReqIndex: number, retryAttempt: number = 0): ApiStream {
let mcpHub: McpHub | undefined
const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds } =
@@ -887,21 +887,29 @@ export class Cline {
// 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 (alwaysApproveResubmit) {
const errorMsg = error.message ?? "Unknown error"
- const requestDelay = requestDelaySeconds || 5
- // Automatically retry with delay
- // Show countdown timer in error color
- for (let i = requestDelay; i > 0; i--) {
+ const baseDelay = requestDelaySeconds || 5
+ const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, retryAttempt))
+
+ // Show countdown timer with exponential backoff
+ for (let i = exponentialDelay; i > 0; i--) {
await this.say(
"api_req_retry_delayed",
- `${errorMsg}\n\nRetrying in ${i} seconds...`,
+ `${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying in ${i} seconds...`,
undefined,
true,
)
await delay(1000)
}
- await this.say("api_req_retry_delayed", `${errorMsg}\n\nRetrying now...`, undefined, false)
- // delegate generator output from the recursive call
- yield* this.attemptApiRequest(previousApiReqIndex)
+
+ await this.say(
+ "api_req_retry_delayed",
+ `${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying now...`,
+ undefined,
+ false,
+ )
+
+ // delegate generator output from the recursive call with incremented retry count
+ yield* this.attemptApiRequest(previousApiReqIndex, retryAttempt + 1)
return
} else {
const { response } = await this.ask(
@@ -1085,35 +1093,23 @@ export class Cline {
const askApproval = async (type: ClineAsk, partialMessage?: string) => {
const { response, text, images } = await this.ask(type, partialMessage, false)
if (response !== "yesButtonClicked") {
- if (response === "messageResponse") {
+ // Handle both messageResponse and noButtonClicked with text
+ if (text) {
await this.say("user_feedback", text, images)
pushToolResult(
formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images),
)
- // this.userMessageContent.push({
- // type: "text",
- // text: `${toolDescription()}`,
- // })
- // this.toolResults.push({
- // type: "tool_result",
- // tool_use_id: toolUseId,
- // content: this.formatToolResponseWithImages(
- // await this.formatToolDeniedFeedback(text),
- // images
- // ),
- // })
- this.didRejectTool = true
- return false
+ } else {
+ pushToolResult(formatResponse.toolDenied())
}
- pushToolResult(formatResponse.toolDenied())
- // this.toolResults.push({
- // type: "tool_result",
- // tool_use_id: toolUseId,
- // content: await this.formatToolDenied(),
- // })
this.didRejectTool = true
return false
}
+ // Handle yesButtonClicked with text
+ if (text) {
+ await this.say("user_feedback", text, images)
+ pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images))
+ }
return true
}
diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts
index 2f46456..f868d17 100644
--- a/src/core/__tests__/Cline.test.ts
+++ b/src/core/__tests__/Cline.test.ts
@@ -730,25 +730,19 @@ describe("Cline", () => {
const iterator = cline.attemptApiRequest(0)
await iterator.next()
+ // Calculate expected delay for first retry
+ const baseDelay = 3 // from requestDelaySeconds
+
// Verify countdown messages
- expect(saySpy).toHaveBeenCalledWith(
- "api_req_retry_delayed",
- expect.stringContaining("Retrying in 3 seconds"),
- undefined,
- true,
- )
- expect(saySpy).toHaveBeenCalledWith(
- "api_req_retry_delayed",
- expect.stringContaining("Retrying in 2 seconds"),
- undefined,
- true,
- )
- expect(saySpy).toHaveBeenCalledWith(
- "api_req_retry_delayed",
- expect.stringContaining("Retrying in 1 seconds"),
- undefined,
- true,
- )
+ for (let i = baseDelay; i > 0; i--) {
+ expect(saySpy).toHaveBeenCalledWith(
+ "api_req_retry_delayed",
+ expect.stringContaining(`Retrying in ${i} seconds`),
+ undefined,
+ true,
+ )
+ }
+
expect(saySpy).toHaveBeenCalledWith(
"api_req_retry_delayed",
expect.stringContaining("Retrying now"),
@@ -757,12 +751,14 @@ describe("Cline", () => {
)
// Verify delay was called correctly
- expect(mockDelay).toHaveBeenCalledTimes(3)
+ expect(mockDelay).toHaveBeenCalledTimes(baseDelay)
expect(mockDelay).toHaveBeenCalledWith(1000)
// Verify error message content
const errorMessage = saySpy.mock.calls.find((call) => call[1]?.includes(mockError.message))?.[1]
- expect(errorMessage).toBe(`${mockError.message}\n\nRetrying in 3 seconds...`)
+ expect(errorMessage).toBe(
+ `${mockError.message}\n\nRetry attempt 1\nRetrying in ${baseDelay} seconds...`,
+ )
})
describe("loadContext", () => {
diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts
index 05f33ba..f06dff3 100644
--- a/src/core/prompts/responses.ts
+++ b/src/core/prompts/responses.ts
@@ -8,6 +8,9 @@ export const formatResponse = {
toolDeniedWithFeedback: (feedback?: string) =>
`The user denied this operation and provided the following feedback:\n
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx
index 0a32eef..37d0b66 100644
--- a/webview-ui/src/components/chat/ChatView.tsx
+++ b/webview-ui/src/components/chat/ChatView.tsx
@@ -275,7 +275,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
return true
} else {
const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started")
- if (lastApiReqStarted && lastApiReqStarted.text != null && lastApiReqStarted.say === "api_req_started") {
+ if (lastApiReqStarted && lastApiReqStarted.text && lastApiReqStarted.say === "api_req_started") {
const cost = JSON.parse(lastApiReqStarted.text).cost
if (cost === undefined) {
// api request has not finished yet
@@ -337,56 +337,96 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
/*
This logic depends on the useEffect[messages] above to set clineAsk, after which buttons are shown and we then send an askResponse to the extension.
*/
- const handlePrimaryButtonClick = useCallback(() => {
- switch (clineAsk) {
- case "api_req_failed":
- case "command":
- case "command_output":
- case "tool":
- case "browser_action_launch":
- case "use_mcp_server":
- case "resume_task":
- case "mistake_limit_reached":
- vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
- break
- case "completion_result":
- case "resume_completed_task":
- // extension waiting for feedback. but we can just present a new task button
- startNewTask()
- break
- }
- setTextAreaDisabled(true)
- setClineAsk(undefined)
- setEnableButtons(false)
- disableAutoScrollRef.current = false
- }, [clineAsk, startNewTask])
+ const handlePrimaryButtonClick = useCallback(
+ (text?: string, images?: string[]) => {
+ const trimmedInput = text?.trim()
+ switch (clineAsk) {
+ case "api_req_failed":
+ case "command":
+ case "command_output":
+ case "tool":
+ case "browser_action_launch":
+ case "use_mcp_server":
+ case "resume_task":
+ case "mistake_limit_reached":
+ // Only send text/images if they exist
+ if (trimmedInput || (images && images.length > 0)) {
+ vscode.postMessage({
+ type: "askResponse",
+ askResponse: "yesButtonClicked",
+ text: trimmedInput,
+ images: images,
+ })
+ } else {
+ vscode.postMessage({
+ type: "askResponse",
+ askResponse: "yesButtonClicked",
+ })
+ }
+ // Clear input state after sending
+ setInputValue("")
+ setSelectedImages([])
+ break
+ case "completion_result":
+ case "resume_completed_task":
+ // extension waiting for feedback. but we can just present a new task button
+ startNewTask()
+ break
+ }
+ setTextAreaDisabled(true)
+ setClineAsk(undefined)
+ setEnableButtons(false)
+ disableAutoScrollRef.current = false
+ },
+ [clineAsk, startNewTask],
+ )
- const handleSecondaryButtonClick = useCallback(() => {
- if (isStreaming) {
- vscode.postMessage({ type: "cancelTask" })
- setDidClickCancel(true)
- return
- }
+ const handleSecondaryButtonClick = useCallback(
+ (text?: string, images?: string[]) => {
+ const trimmedInput = text?.trim()
+ if (isStreaming) {
+ vscode.postMessage({ type: "cancelTask" })
+ setDidClickCancel(true)
+ return
+ }
- switch (clineAsk) {
- case "api_req_failed":
- case "mistake_limit_reached":
- case "resume_task":
- startNewTask()
- break
- case "command":
- case "tool":
- case "browser_action_launch":
- case "use_mcp_server":
- // responds to the API with a "This operation failed" and lets it try again
- vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
- break
- }
- setTextAreaDisabled(true)
- setClineAsk(undefined)
- setEnableButtons(false)
- disableAutoScrollRef.current = false
- }, [clineAsk, startNewTask, isStreaming])
+ switch (clineAsk) {
+ case "api_req_failed":
+ case "mistake_limit_reached":
+ case "resume_task":
+ startNewTask()
+ break
+ case "command":
+ case "tool":
+ case "browser_action_launch":
+ case "use_mcp_server":
+ // Only send text/images if they exist
+ if (trimmedInput || (images && images.length > 0)) {
+ vscode.postMessage({
+ type: "askResponse",
+ askResponse: "noButtonClicked",
+ text: trimmedInput,
+ images: images,
+ })
+ } else {
+ // responds to the API with a "This operation failed" and lets it try again
+ vscode.postMessage({
+ type: "askResponse",
+ askResponse: "noButtonClicked",
+ })
+ }
+ // Clear input state after sending
+ setInputValue("")
+ setSelectedImages([])
+ break
+ }
+ setTextAreaDisabled(true)
+ setClineAsk(undefined)
+ setEnableButtons(false)
+ disableAutoScrollRef.current = false
+ },
+ [clineAsk, startNewTask, isStreaming],
+ )
const handleTaskCloseButtonClick = useCallback(() => {
startNewTask()
@@ -430,10 +470,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
handleSendMessage(message.text ?? "", message.images ?? [])
break
case "primaryButtonClick":
- handlePrimaryButtonClick()
+ handlePrimaryButtonClick(message.text ?? "", message.images ?? [])
break
case "secondaryButtonClick":
- handleSecondaryButtonClick()
+ handleSecondaryButtonClick(message.text ?? "", message.images ?? [])
break
}
}
@@ -660,9 +700,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
if (message.say === "api_req_started") {
// get last api_req_started in currentGroup to check if it's cancelled. If it is then this api req is not part of the current browser session
const lastApiReqStarted = [...currentGroup].reverse().find((m) => m.say === "api_req_started")
- if (lastApiReqStarted?.text != null) {
+ if (lastApiReqStarted?.text) {
const info = JSON.parse(lastApiReqStarted.text)
- const isCancelled = info.cancelReason != null
+ const isCancelled = info.cancelReason !== null
if (isCancelled) {
endBrowserSession()
result.push(message)
@@ -1038,7 +1078,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
flex: secondaryButtonText ? 1 : 2,
marginRight: secondaryButtonText ? "6px" : "0",
}}
- onClick={handlePrimaryButtonClick}>
+ onClick={(e) => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
)}
@@ -1050,7 +1090,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
flex: isStreaming ? 2 : 1,
marginLeft: isStreaming ? 0 : "6px",
}}
- onClick={handleSecondaryButtonClick}>
+ onClick={(e) => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? "Cancel" : secondaryButtonText}
)}
diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx
index 3e3bbd7..d72d856 100644
--- a/webview-ui/src/components/welcome/WelcomeView.tsx
+++ b/webview-ui/src/components/welcome/WelcomeView.tsx
@@ -10,7 +10,7 @@ const WelcomeView = () => {
const [apiErrorMessage, setApiErrorMessage] = useState