From e4091c3686339032ae1bb6303af2e8b2a1fad398 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:53:41 -0700 Subject: [PATCH] Stop the task at the mistake limit like the CLI and remove the max-mistakes setting (#12561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vscode): stop the task at the mistake limit like the CLI, drop the max-mistakes setting When the SDK's consecutive-mistake limit is hit, the extension used to block on an ask (Proceed Anyways / Start New Task) while the agent loop kept running against the provider — reproduced 2,100+ consecutive API requests behind the unanswered prompt. Replicate the CLI's non-interactive resolver instead: show an error row and resolve the decision as an immediate stop. The run aborts cleanly at the turn boundary, the turn phase becomes awaiting_followup, and the user continues whenever they want by sending a new message (which also resets the SDK's mistake tracking on the next productive turn). Also remove the extension's maxConsecutiveMistakes setting (state key, settings RPC, webview state, proto fields now reserved). It was never wired into the SDK session config — the SDK's own default governs — so the setting was dead weight. Legacy mistake_limit_reached asks from persisted conversations still render via the existing webview paths. * fix(proto): reserve retired Settings field 139 (max_consecutive_mistakes) The original removal added 'reserved 139' but the proto generator at the branch base had no reserved-statement support and silently dropped it on regeneration. Main (b4c640733) taught generate-state-proto.mjs to preserve reserved statements, so after the merge the reservation now survives. Also reserve the field name, mirroring the custom_prompt removal pattern. --------- Co-authored-by: Cursor Agent --- apps/vscode/proto/cline/state.proto | 5 +- .../state/getStateToPostToWebview.ts | 2 - .../core/controller/state/updateSettings.ts | 5 - .../client/host-grpc-client-base.ts | 7 +- .../src/sdk/sdk-followup-coordinator.test.ts | 2 - .../src/sdk/sdk-followup-coordinator.ts | 4 - .../sdk/sdk-interaction-coordinator.test.ts | 101 +++++++----------- .../src/sdk/sdk-interaction-coordinator.ts | 64 +++-------- apps/vscode/src/shared/ExtensionMessage.ts | 1 - apps/vscode/src/shared/storage/state-keys.ts | 1 - .../src/components/chat/BrowserSessionRow.tsx | 2 +- .../chat/chat-view/shared/buttonConfig.ts | 10 +- .../chat/chat-view/utils/messageUtils.ts | 11 +- .../src/context/ExtensionStateContext.tsx | 1 - 14 files changed, 67 insertions(+), 149 deletions(-) diff --git a/apps/vscode/proto/cline/state.proto b/apps/vscode/proto/cline/state.proto index 4af1edbf47..3f9df55d4c 100644 --- a/apps/vscode/proto/cline/state.proto +++ b/apps/vscode/proto/cline/state.proto @@ -114,6 +114,8 @@ message Secrets { message Settings { reserved 150; // was custom_prompt (removed - compact prompt setting no longer supported) reserved "custom_prompt"; + reserved 139; // was max_consecutive_mistakes (removed; the SDK owns the mistake limit) + reserved "max_consecutive_mistakes"; optional string lite_llm_base_url = 1; optional bool lite_llm_use_prompt_cache = 2; optional string anthropic_base_url = 4; @@ -250,7 +252,6 @@ message Settings { optional bool enable_checkpoints_setting = 135; optional int32 shell_integration_timeout = 136; optional string default_terminal_profile = 137; - optional int32 max_consecutive_mistakes = 139; optional bool yolo_mode_toggled = 142; optional bool use_auto_condense = 143; optional string preferred_language = 145; @@ -417,7 +418,7 @@ message UpdateSettingsRequest { optional bool multi_root_enabled = 25; optional bool hooks_enabled = 26; optional string vscode_terminal_execution_mode = 27; - optional int32 max_consecutive_mistakes = 28; + reserved 28; // was max_consecutive_mistakes (removed; the SDK owns the mistake limit) optional bool subagents_enabled = 29; optional int32 subagent_terminal_output_line_limit = 30; optional string cline_env = 31; diff --git a/apps/vscode/src/core/controller/state/getStateToPostToWebview.ts b/apps/vscode/src/core/controller/state/getStateToPostToWebview.ts index e6fc131be6..58c030b5b7 100644 --- a/apps/vscode/src/core/controller/state/getStateToPostToWebview.ts +++ b/apps/vscode/src/core/controller/state/getStateToPostToWebview.ts @@ -64,7 +64,6 @@ export async function getStateToPostToWebview(controller: { const welcomeViewCompleted = !!stateManager.getGlobalStateKey("welcomeViewCompleted") const mcpResponsesCollapsed = stateManager.getGlobalStateKey("mcpResponsesCollapsed") - const maxConsecutiveMistakes = stateManager.getGlobalSettingsKey("maxConsecutiveMistakes") const favoritedModelIds = stateManager.getGlobalStateKey("favoritedModelIds") const lastDismissedInfoBannerVersion = stateManager.getGlobalStateKey("lastDismissedInfoBannerVersion") || 0 const lastDismissedModelBannerVersion = stateManager.getGlobalStateKey("lastDismissedModelBannerVersion") || 0 @@ -150,7 +149,6 @@ export async function getStateToPostToWebview(controller: { welcomeViewCompleted, onboardingModels, mcpResponsesCollapsed, - maxConsecutiveMistakes, taskHistory: processedTaskHistory, shouldShowAnnouncement, favoritedModelIds, diff --git a/apps/vscode/src/core/controller/state/updateSettings.ts b/apps/vscode/src/core/controller/state/updateSettings.ts index 57824bd226..856f1e4857 100644 --- a/apps/vscode/src/core/controller/state/updateSettings.ts +++ b/apps/vscode/src/core/controller/state/updateSettings.ts @@ -130,11 +130,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett controller.handleTerminalExecutionModeChanged(previousMode, nextMode) } - // Update max consecutive mistakes - if (request.maxConsecutiveMistakes !== undefined) { - controller.stateManager.setGlobalState("maxConsecutiveMistakes", Number(request.maxConsecutiveMistakes)) - } - if (request.hooksEnabled !== undefined) { const wasEnabled = controller.stateManager.getGlobalSettingsKey("hooksEnabled") ?? true const isEnabled = !!request.hooksEnabled diff --git a/apps/vscode/src/hosts/vscode/hostbridge/client/host-grpc-client-base.ts b/apps/vscode/src/hosts/vscode/hostbridge/client/host-grpc-client-base.ts index 6dcc88dc02..12bdd9ae04 100644 --- a/apps/vscode/src/hosts/vscode/hostbridge/client/host-grpc-client-base.ts +++ b/apps/vscode/src/hosts/vscode/hostbridge/client/host-grpc-client-base.ts @@ -66,11 +66,10 @@ export function createGrpcClient(service: T): GrpcClient // If the result is a function, it's the cancel function if (typeof result === "function") { return result - } else { - // This shouldn't happen, but just in case - Logger.error(`Expected cancel function but got response object for streaming request: ${requestId}`) - return () => {} } + // This shouldn't happen, but just in case + Logger.error(`Expected cancel function but got response object for streaming request: ${requestId}`) + return () => {} } catch (error) { Logger.error(`Error in streaming request: ${error}`) if (options.onError) { diff --git a/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts b/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts index 202844640f..143ab16985 100644 --- a/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-followup-coordinator.test.ts @@ -413,7 +413,6 @@ function makeCoordinator(input: Partial = {}) { getGlobalSettingsKey: vi.fn(() => input.mode ?? "act"), } as unknown as StateManager, interactions: { - resolvePendingMistakeLimit: vi.fn(() => false), resolvePendingToolApproval: vi.fn(() => false), resolvePendingAskQuestion: vi.fn(() => false), }, @@ -454,7 +453,6 @@ function makeCoordinator(input: Partial = {}) { onResumeFailed: vi.fn(), } as unknown as SdkFollowupCoordinatorOptions & { interactions: SdkFollowupCoordinatorOptions["interactions"] & { - resolvePendingMistakeLimit: ReturnType resolvePendingToolApproval: ReturnType resolvePendingAskQuestion: ReturnType } diff --git a/apps/vscode/src/sdk/sdk-followup-coordinator.ts b/apps/vscode/src/sdk/sdk-followup-coordinator.ts index 9a6345476d..dd0f503b73 100644 --- a/apps/vscode/src/sdk/sdk-followup-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-followup-coordinator.ts @@ -54,10 +54,6 @@ export class SdkFollowupCoordinator { askResponse?: ClineAskResponse, turnPhaseAtSubmit?: TurnPhase, ): Promise { - if (this.options.interactions.resolvePendingMistakeLimit(prompt, askResponse)) { - return - } - if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse, images, files)) { return } diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts index 232a3d6121..2e57f66251 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts @@ -358,7 +358,7 @@ describe("SdkInteractionCoordinator", () => { ]) }) - it("emits mistake_limit_reached and resolves proceed as SDK recovery guidance", async () => { + it("shows an error row and stops immediately when the mistake limit is reached", async () => { const task = createTaskProxy("session-123", vi.fn(), vi.fn()) const setTurnPhase = vi.fn() const coordinator = new SdkInteractionCoordinator({ @@ -368,63 +368,35 @@ describe("SdkInteractionCoordinator", () => { setTurnPhase, }) - const decisionPromise = coordinator.handleConsecutiveMistakeLimitReached({ - iteration: 4, - consecutiveMistakes: 3, - maxConsecutiveMistakes: 3, - reason: "tool_execution_failed", - details: "bad arguments", - }) - await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) - - expect(task.messageStateHandler.getClineMessages()[0]).toMatchObject({ - type: "ask", - ask: "mistake_limit_reached", - partial: false, - }) - expect(setTurnPhase).toHaveBeenCalledWith("error", task.messageStateHandler.getClineMessages()[0].ts) - - expect(coordinator.resolvePendingMistakeLimit("try smaller steps", "yesButtonClicked")).toBe(true) - await expect(decisionPromise).resolves.toEqual({ - action: "continue", - guidance: "mistake_limit_reached: try smaller steps", - }) - expect(task.messageStateHandler.getClineMessages()).toMatchObject([ - { type: "ask", ask: "mistake_limit_reached" }, - { type: "say", say: "user_feedback", text: "try smaller steps" }, - ]) - expect(setTurnPhase).toHaveBeenLastCalledWith("streaming") - }) - - it("resolves mistake-limit no-button responses as stop decisions", async () => { - const task = createTaskProxy("session-123", vi.fn(), vi.fn()) - const setTurnPhase = vi.fn() - const coordinator = new SdkInteractionCoordinator({ - messages: new SdkMessageCoordinator({ getTask: () => task }), - getSessionId: () => "session-123", - postStateToWebview: vi.fn().mockResolvedValue(undefined), - setTurnPhase, - }) - - const decisionPromise = coordinator.handleConsecutiveMistakeLimitReached({ - iteration: 4, - consecutiveMistakes: 3, - maxConsecutiveMistakes: 3, - reason: "tool_execution_failed", - }) - await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) - - expect(coordinator.resolvePendingMistakeLimit(undefined, "noButtonClicked")).toBe(true) - - await expect(decisionPromise).resolves.toEqual({ + // CLI parity: the decision resolves right away as a stop — no pending + // prompt that would leave the agent loop running against the provider. + await expect( + coordinator.handleConsecutiveMistakeLimitReached({ + iteration: 4, + consecutiveMistakes: 3, + maxConsecutiveMistakes: 3, + reason: "tool_execution_failed", + details: "bad arguments", + }), + ).resolves.toEqual({ action: "stop", - reason: "stopped after mistake_limit_reached prompt", + reason: "mistake_limit_reached: tool_execution_failed: bad arguments", }) - expect(task.messageStateHandler.getClineMessages()).toMatchObject([{ type: "ask", ask: "mistake_limit_reached" }]) - expect(setTurnPhase).toHaveBeenLastCalledWith("streaming") + + expect(task.messageStateHandler.getClineMessages()).toMatchObject([ + { + type: "say", + say: "error", + partial: false, + }, + ]) + const errorText = task.messageStateHandler.getClineMessages()[0].text ?? "" + expect(errorText).toContain("3 errors in a row") + expect(errorText).toContain("tool_execution_failed: bad arguments") + expect(errorText).toContain("Send a message to give Cline guidance") }) - it("clears pending mistake-limit prompts as stop decisions", async () => { + it("summarizes the mistake limit without details using the iteration", async () => { const task = createTaskProxy("session-123", vi.fn(), vi.fn()) const coordinator = new SdkInteractionCoordinator({ messages: new SdkMessageCoordinator({ getTask: () => task }), @@ -432,18 +404,17 @@ describe("SdkInteractionCoordinator", () => { postStateToWebview: vi.fn().mockResolvedValue(undefined), }) - const decisionPromise = coordinator.handleConsecutiveMistakeLimitReached({ - iteration: 4, - consecutiveMistakes: 3, - maxConsecutiveMistakes: 3, - reason: "tool_execution_failed", + await expect( + coordinator.handleConsecutiveMistakeLimitReached({ + iteration: 4, + consecutiveMistakes: 3, + maxConsecutiveMistakes: 3, + reason: "tool_execution_failed", + }), + ).resolves.toEqual({ + action: "stop", + reason: "mistake_limit_reached: tool_execution_failed at iteration 4", }) - await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)) - - coordinator.clearPending("Task cleared") - - await expect(decisionPromise).resolves.toEqual({ action: "stop", reason: "Task cleared" }) - expect(coordinator.resolvePendingMistakeLimit(undefined, "yesButtonClicked")).toBe(false) }) it("clears pending tool approvals as rejected", async () => { diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts index 99e029e90a..d68b0f0fb4 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts @@ -47,7 +47,6 @@ export interface SdkInteractionCoordinatorOptions { export class SdkInteractionCoordinator { private pendingAskResolve: ((answer: string) => void) | undefined private pendingToolApprovalResolve: ((result: { approved: boolean; reason?: string }) => void) | undefined - private pendingMistakeLimitResolve: ((decision: ConsecutiveMistakeLimitDecision) => void) | undefined private pendingToolApprovalMessage: | { toolCallId: string @@ -58,29 +57,33 @@ export class SdkInteractionCoordinator { constructor(private readonly options: SdkInteractionCoordinatorOptions) {} + /** + * CLI-parity mistake-limit handling: show an error row and stop the run + * immediately. The session stays resumable, so the user continues + * whenever they want by sending a new message (which also resets the + * SDK's mistake tracking). A blocking ask here would leave the agent + * loop running against the provider while the prompt sits unanswered. + */ async handleConsecutiveMistakeLimitReached( context: ConsecutiveMistakeLimitContext, ): Promise { const detail = context.details?.trim() const latest = detail ? `${context.reason}: ${detail}` : `${context.reason} at iteration ${context.iteration}` - const askMessage: ClineMessage = { + const errorMessage: ClineMessage = { ts: this.nextMessageTs(), - type: "ask", - ask: "mistake_limit_reached", - text: `Cline ran into repeated tool errors (${context.consecutiveMistakes}/${context.maxConsecutiveMistakes}).\n\nLatest: ${latest}`, + type: "say", + say: "error", + text: `Cline ran into ${context.consecutiveMistakes} errors in a row and stopped the task.\n\nLatest: ${latest}\n\nSend a message to give Cline guidance and continue the task.`, partial: false, } - this.options.messages.appendAndEmit([askMessage], { + this.options.messages.appendAndEmit([errorMessage], { type: "status", payload: { sessionId: this.options.getSessionId(), status: "running" }, }) - this.options.setTurnPhase?.("error", askMessage.ts) await this.options.postStateToWebview() - return new Promise((resolve) => { - this.pendingMistakeLimitResolve = resolve - }) + return { action: "stop", reason: `mistake_limit_reached: ${latest}` } } async handleRequestToolApproval(request: ToolApprovalRequest): Promise<{ approved: boolean; reason?: string }> { @@ -232,49 +235,8 @@ export class SdkInteractionCoordinator { return true } - resolvePendingMistakeLimit(prompt: string | undefined, responseType: ClineAskResponse | undefined): boolean { - if (!this.pendingMistakeLimitResolve) { - return false - } - - const resolve = this.pendingMistakeLimitResolve - this.pendingMistakeLimitResolve = undefined - this.options.setTurnPhase?.("streaming") - - if (responseType === "noButtonClicked") { - resolve({ action: "stop", reason: "stopped after mistake_limit_reached prompt" }) - return true - } - - const trimmedPrompt = prompt?.trim() - if (trimmedPrompt) { - const userMessage: ClineMessage = { - ts: this.nextMessageTs(), - type: "say", - say: "user_feedback", - text: trimmedPrompt, - partial: false, - } - this.options.messages.appendAndEmit([userMessage], { - type: "status", - payload: { sessionId: this.options.getSessionId(), status: "running" }, - }) - } - - const guidance = trimmedPrompt - ? `mistake_limit_reached: ${trimmedPrompt}` - : "mistake_limit_reached: retry with a different approach, validate tool parameters before calls, and avoid repeating failed steps." - - resolve({ action: "continue", guidance }) - return true - } - clearPending(reason: string): void { this.pendingAskResolve = undefined - if (this.pendingMistakeLimitResolve) { - this.pendingMistakeLimitResolve({ action: "stop", reason }) - this.pendingMistakeLimitResolve = undefined - } const pendingMessage = this.pendingToolApprovalMessage this.pendingToolApprovalMessage = undefined if (this.pendingToolApprovalResolve) { diff --git a/apps/vscode/src/shared/ExtensionMessage.ts b/apps/vscode/src/shared/ExtensionMessage.ts index 28ac65cdcb..31fea686b6 100644 --- a/apps/vscode/src/shared/ExtensionMessage.ts +++ b/apps/vscode/src/shared/ExtensionMessage.ts @@ -88,7 +88,6 @@ export interface ExtensionState { telemetrySetting: TelemetrySetting shellIntegrationTimeout: number terminalReuseEnabled?: boolean - maxConsecutiveMistakes: number defaultTerminalProfile?: string vscodeTerminalExecutionMode: string backgroundCommandRunning?: boolean diff --git a/apps/vscode/src/shared/storage/state-keys.ts b/apps/vscode/src/shared/storage/state-keys.ts index aa1ec1112a..216eaab1e6 100644 --- a/apps/vscode/src/shared/storage/state-keys.ts +++ b/apps/vscode/src/shared/storage/state-keys.ts @@ -272,7 +272,6 @@ const USER_SETTINGS_FIELDS = { enableCheckpointsSetting: { default: true as boolean }, shellIntegrationTimeout: { default: 4000 as number }, defaultTerminalProfile: { default: "default" as string }, - maxConsecutiveMistakes: { default: 3 as number }, hooksEnabled: { default: true as boolean }, yoloModeToggled: { default: false as boolean }, autoApproveAllToggled: { default: false as boolean }, diff --git a/apps/vscode/webview-ui/src/components/chat/BrowserSessionRow.tsx b/apps/vscode/webview-ui/src/components/chat/BrowserSessionRow.tsx index 9a4ce34d0d..807d53f0dd 100644 --- a/apps/vscode/webview-ui/src/components/chat/BrowserSessionRow.tsx +++ b/apps/vscode/webview-ui/src/components/chat/BrowserSessionRow.tsx @@ -356,7 +356,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => { {isBrowsing && !isLastMessageResume ? ( ) : ( - + )} {isAutoApproved ? "Cline is using the browser:" : "Cline wants to use the browser:"} diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.ts index 8fc02b91fe..09ba1aa63c 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/shared/buttonConfig.ts @@ -335,13 +335,9 @@ function isInertStatusMessage(message: ClineMessage): boolean { } } - return [ - "api_req_finished", - "deleted_api_reqs", - "mcp_server_request_started", - "subagent_usage", - "task_progress", - ].includes(message.say || "") + return ["api_req_finished", "deleted_api_reqs", "mcp_server_request_started", "subagent_usage", "task_progress"].includes( + message.say || "", + ) } /** diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/utils/messageUtils.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/utils/messageUtils.ts index 0c7a91d230..b579924279 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/utils/messageUtils.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/utils/messageUtils.ts @@ -184,9 +184,14 @@ function isBrowserSessionMessage(message: ClineMessage): boolean { return message.ask === "browser_action_launch" } if (message.type === "say") { - return ["browser_action_launch", "api_req_started", "text", "browser_action", "browser_action_result", "reasoning"].includes( - message.say ?? "", - ) + return [ + "browser_action_launch", + "api_req_started", + "text", + "browser_action", + "browser_action_result", + "reasoning", + ].includes(message.say ?? "") } return false } diff --git a/apps/vscode/webview-ui/src/context/ExtensionStateContext.tsx b/apps/vscode/webview-ui/src/context/ExtensionStateContext.tsx index ad6542486d..c1fafbf899 100644 --- a/apps/vscode/webview-ui/src/context/ExtensionStateContext.tsx +++ b/apps/vscode/webview-ui/src/context/ExtensionStateContext.tsx @@ -292,7 +292,6 @@ export const ExtensionStateContextProvider: React.FC<{ shellIntegrationTimeout: 4000, terminalReuseEnabled: true, vscodeTerminalExecutionMode: "backgroundExec", - maxConsecutiveMistakes: 3, defaultTerminalProfile: "default", isNewUser: false, welcomeViewCompleted: false,