mirror of
https://github.com/cline/cline.git
synced 2026-08-30 17:20:20 +08:00
Stop the task at the mistake limit like the CLI and remove the max-mistakes setting (#12561)
* 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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,11 +66,10 @@ export function createGrpcClient<T extends ProtoService>(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) {
|
||||
|
||||
@@ -413,7 +413,6 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
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<MakeCoordinatorInput> = {}) {
|
||||
onResumeFailed: vi.fn(),
|
||||
} as unknown as SdkFollowupCoordinatorOptions & {
|
||||
interactions: SdkFollowupCoordinatorOptions["interactions"] & {
|
||||
resolvePendingMistakeLimit: ReturnType<typeof vi.fn>
|
||||
resolvePendingToolApproval: ReturnType<typeof vi.fn>
|
||||
resolvePendingAskQuestion: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
@@ -54,10 +54,6 @@ export class SdkFollowupCoordinator {
|
||||
askResponse?: ClineAskResponse,
|
||||
turnPhaseAtSubmit?: TurnPhase,
|
||||
): Promise<void> {
|
||||
if (this.options.interactions.resolvePendingMistakeLimit(prompt, askResponse)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse, images, files)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<ConsecutiveMistakeLimitDecision> {
|
||||
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<ConsecutiveMistakeLimitDecision>((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) {
|
||||
|
||||
@@ -88,7 +88,6 @@ export interface ExtensionState {
|
||||
telemetrySetting: TelemetrySetting
|
||||
shellIntegrationTimeout: number
|
||||
terminalReuseEnabled?: boolean
|
||||
maxConsecutiveMistakes: number
|
||||
defaultTerminalProfile?: string
|
||||
vscodeTerminalExecutionMode: string
|
||||
backgroundCommandRunning?: boolean
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -356,7 +356,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
||||
{isBrowsing && !isLastMessageResume ? (
|
||||
<ProgressIndicator />
|
||||
) : (
|
||||
<span className="codicon codicon-inspect" style={browserIconStyle}></span>
|
||||
<span className="codicon codicon-inspect" style={browserIconStyle} />
|
||||
)}
|
||||
<span style={approveTextStyle}>
|
||||
{isAutoApproved ? "Cline is using the browser:" : "Cline wants to use the browser:"}
|
||||
|
||||
@@ -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 || "",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -292,7 +292,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
shellIntegrationTimeout: 4000,
|
||||
terminalReuseEnabled: true,
|
||||
vscodeTerminalExecutionMode: "backgroundExec",
|
||||
maxConsecutiveMistakes: 3,
|
||||
defaultTerminalProfile: "default",
|
||||
isNewUser: false,
|
||||
welcomeViewCompleted: false,
|
||||
|
||||
Reference in New Issue
Block a user