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\n${feedback}\n`, + toolApprovedWithFeedback: (feedback?: string) => + `The user approved this operation and provided the following context:\n\n${feedback}\n`, + toolError: (error?: string) => `The tool execution failed with the following error:\n\n${error}\n`, noToolsUsed: () => diff --git a/src/core/prompts/sections/modes.ts b/src/core/prompts/sections/modes.ts index 9eb6f82..ef09c03 100644 --- a/src/core/prompts/sections/modes.ts +++ b/src/core/prompts/sections/modes.ts @@ -16,13 +16,17 @@ MODES ${modes.map((mode: ModeConfig) => ` * "${mode.name}" mode - ${mode.roleDefinition.split(".")[0]}`).join("\n")} Custom modes will be referred to by their configured name property. -- Custom modes can be configured by creating or editing the custom modes file at '${customModesPath}'. The following fields are required and must not be empty: +- Custom modes can be configured by editing the custom modes file at '${customModesPath}'. The file gets created automatically on startup and should always exist. Make sure to read the latest contents before writing to it to avoid overwriting existing modes. + +- The following fields are required and must not be empty: * slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better. * name: The display name for the mode * roleDefinition: A detailed description of the mode's role and capabilities * groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }] to only allow editing markdown files) -The customInstructions field is optional. +- The customInstructions field is optional. + +- For multi-line text, include newline characters in the string like "This is the first line.\nThis is the next line.\n\nThis is a double line break." The file should follow this structure: { diff --git a/webview-ui/.eslintrc.json b/webview-ui/.eslintrc.json deleted file mode 100644 index 0c69367..0000000 --- a/webview-ui/.eslintrc.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "root": true, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended" - ], - "parser": "@typescript-eslint/parser", - "plugins": ["react", "@typescript-eslint", "react-hooks"], - "rules": { - "react/react-in-jsx-scope": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-explicit-any": "warn", - "react/display-name": "warn", - "no-case-declarations": "warn", - "react/no-unescaped-entities": "warn", - "react/jsx-key": "warn", - "no-extra-semi": "warn", - "@typescript-eslint/no-var-requires": "warn", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } - ] - }, - "settings": { - "react": { - "version": "detect" - } - }, - "env": { - "browser": true, - "es2021": true, - "node": true - } -} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 1ec109f..ffa340e 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -89,7 +89,7 @@ export const ChatRowContent = ({ } }, [isLast, message.say]) const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { - if (message.text != null && message.say === "api_req_started") { + if (message.text && message.say === "api_req_started") { const info: ClineApiReqInfo = JSON.parse(message.text) return [info.cost, info.cancelReason, info.streamingFailedMessage] } @@ -183,26 +183,26 @@ export const ChatRowContent = ({ ) return [ - apiReqCancelReason != null ? ( + apiReqCancelReason !== null ? ( apiReqCancelReason === "user_cancelled" ? ( getIconSpan("error", cancelledColor) ) : ( getIconSpan("error", errorColor) ) - ) : cost != null ? ( + ) : cost !== null ? ( getIconSpan("check", successColor) ) : apiRequestFailedMessage ? ( getIconSpan("error", errorColor) ) : ( ), - apiReqCancelReason != null ? ( + apiReqCancelReason !== null ? ( apiReqCancelReason === "user_cancelled" ? ( API Request Cancelled ) : ( API Streaming Failed ) - ) : cost != null ? ( + ) : cost !== null ? ( API Request ) : apiRequestFailedMessage ? ( API Request Failed @@ -510,7 +510,7 @@ export const ChatRowContent = ({ style={{ ...headerStyle, marginBottom: - (cost == null && apiRequestFailedMessage) || apiReqStreamingFailedMessage + (cost === null && apiRequestFailedMessage) || apiReqStreamingFailedMessage ? 10 : 0, justifyContent: "space-between", @@ -524,13 +524,13 @@ export const ChatRowContent = ({
{icon} {title} - 0 ? 1 : 0 }}> + ${Number(cost || 0)?.toFixed(4)}
- {((cost == null && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && ( + {((cost === null && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && ( <>

{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(undefined) - const disableLetsGoButton = apiErrorMessage != null + const disableLetsGoButton = !!apiErrorMessage const handleSubmit = () => { vscode.postMessage({ type: "apiConfiguration", apiConfiguration })