Skip to content

Commit 93f88b4

Browse files
MuriloFPdaniel-lxs
andauthored
feat: Add configurable error & repetition limit with unified control (RooCodeInc#5654) (RooCodeInc#5752)
Co-authored-by: Daniel Riccio <[email protected]>
1 parent 5557d77 commit 93f88b4

28 files changed

+369
-5
lines changed

packages/types/src/provider-settings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,18 @@ export type ProviderSettingsEntry = z.infer<typeof providerSettingsEntrySchema>
5353
* ProviderSettings
5454
*/
5555

56+
/**
57+
* Default value for consecutive mistake limit
58+
*/
59+
export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3
60+
5661
const baseProviderSettingsSchema = z.object({
5762
includeMaxTokens: z.boolean().optional(),
5863
diffEnabled: z.boolean().optional(),
5964
fuzzyMatchThreshold: z.number().optional(),
6065
modelTemperature: z.number().nullish(),
6166
rateLimitSeconds: z.number().optional(),
67+
consecutiveMistakeLimit: z.number().min(0).optional(),
6268

6369
// Model reasoning.
6470
enableReasoningEffort: z.boolean().optional(),

src/core/config/ProviderSettingsManager.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type ProviderSettingsEntry,
66
providerSettingsSchema,
77
providerSettingsSchemaDiscriminated,
8+
DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
89
} from "@roo-code/types"
910
import { TelemetryService } from "@roo-code/telemetry"
1011

@@ -26,6 +27,7 @@ export const providerProfilesSchema = z.object({
2627
rateLimitSecondsMigrated: z.boolean().optional(),
2728
diffSettingsMigrated: z.boolean().optional(),
2829
openAiHeadersMigrated: z.boolean().optional(),
30+
consecutiveMistakeLimitMigrated: z.boolean().optional(),
2931
})
3032
.optional(),
3133
})
@@ -48,6 +50,7 @@ export class ProviderSettingsManager {
4850
rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs
4951
diffSettingsMigrated: true, // Mark as migrated on fresh installs
5052
openAiHeadersMigrated: true, // Mark as migrated on fresh installs
53+
consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs
5154
},
5255
}
5356

@@ -113,6 +116,7 @@ export class ProviderSettingsManager {
113116
rateLimitSecondsMigrated: false,
114117
diffSettingsMigrated: false,
115118
openAiHeadersMigrated: false,
119+
consecutiveMistakeLimitMigrated: false,
116120
} // Initialize with default values
117121
isDirty = true
118122
}
@@ -135,6 +139,12 @@ export class ProviderSettingsManager {
135139
isDirty = true
136140
}
137141

142+
if (!providerProfiles.migrations.consecutiveMistakeLimitMigrated) {
143+
await this.migrateConsecutiveMistakeLimit(providerProfiles)
144+
providerProfiles.migrations.consecutiveMistakeLimitMigrated = true
145+
isDirty = true
146+
}
147+
138148
if (isDirty) {
139149
await this.store(providerProfiles)
140150
}
@@ -228,6 +238,18 @@ export class ProviderSettingsManager {
228238
}
229239
}
230240

241+
private async migrateConsecutiveMistakeLimit(providerProfiles: ProviderProfiles) {
242+
try {
243+
for (const [name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) {
244+
if (apiConfig.consecutiveMistakeLimit == null) {
245+
apiConfig.consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT
246+
}
247+
}
248+
} catch (error) {
249+
console.error(`[MigrateConsecutiveMistakeLimit] Failed to migrate consecutive mistake limit:`, error)
250+
}
251+
}
252+
231253
/**
232254
* List all available configs with metadata.
233255
*/

src/core/config/__tests__/ProviderSettingsManager.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe("ProviderSettingsManager", () => {
6666
rateLimitSecondsMigrated: true,
6767
diffSettingsMigrated: true,
6868
openAiHeadersMigrated: true,
69+
consecutiveMistakeLimitMigrated: true,
6970
},
7071
}),
7172
)
@@ -144,6 +145,47 @@ describe("ProviderSettingsManager", () => {
144145
expect(storedConfig.apiConfigs.existing.rateLimitSeconds).toEqual(43)
145146
})
146147

148+
it("should call migrateConsecutiveMistakeLimit if it has not done so already", async () => {
149+
mockSecrets.get.mockResolvedValue(
150+
JSON.stringify({
151+
currentApiConfigName: "default",
152+
apiConfigs: {
153+
default: {
154+
config: {},
155+
id: "default",
156+
consecutiveMistakeLimit: undefined,
157+
},
158+
test: {
159+
apiProvider: "anthropic",
160+
consecutiveMistakeLimit: undefined,
161+
},
162+
existing: {
163+
apiProvider: "anthropic",
164+
// this should not really be possible, unless someone has loaded a hand edited config,
165+
// but we don't overwrite so we'll check that
166+
consecutiveMistakeLimit: 5,
167+
},
168+
},
169+
migrations: {
170+
rateLimitSecondsMigrated: true,
171+
diffSettingsMigrated: true,
172+
openAiHeadersMigrated: true,
173+
consecutiveMistakeLimitMigrated: false,
174+
},
175+
}),
176+
)
177+
178+
await providerSettingsManager.initialize()
179+
180+
// Get the last call to store, which should contain the migrated config
181+
const calls = mockSecrets.store.mock.calls
182+
const storedConfig = JSON.parse(calls[calls.length - 1][1])
183+
expect(storedConfig.apiConfigs.default.consecutiveMistakeLimit).toEqual(3)
184+
expect(storedConfig.apiConfigs.test.consecutiveMistakeLimit).toEqual(3)
185+
expect(storedConfig.apiConfigs.existing.consecutiveMistakeLimit).toEqual(5)
186+
expect(storedConfig.migrations.consecutiveMistakeLimitMigrated).toEqual(true)
187+
})
188+
147189
it("should throw error if secrets storage fails", async () => {
148190
mockSecrets.get.mockRejectedValue(new Error("Storage failed"))
149191

src/core/task/Task.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type ClineMessage,
1919
type ClineSay,
2020
type ToolProgressStatus,
21+
DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
2122
type HistoryItem,
2223
TelemetryEventName,
2324
TodoItem,
@@ -216,7 +217,7 @@ export class Task extends EventEmitter<ClineEvents> {
216217
enableDiff = false,
217218
enableCheckpoints = true,
218219
fuzzyMatchThreshold = 1.0,
219-
consecutiveMistakeLimit = 3,
220+
consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
220221
task,
221222
images,
222223
historyItem,
@@ -255,7 +256,7 @@ export class Task extends EventEmitter<ClineEvents> {
255256
this.browserSession = new BrowserSession(provider.context)
256257
this.diffEnabled = enableDiff
257258
this.fuzzyMatchThreshold = fuzzyMatchThreshold
258-
this.consecutiveMistakeLimit = consecutiveMistakeLimit
259+
this.consecutiveMistakeLimit = consecutiveMistakeLimit ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT
259260
this.providerRef = new WeakRef(provider)
260261
this.globalStoragePath = provider.context.globalStorageUri.fsPath
261262
this.diffViewProvider = new DiffViewProvider(this.cwd)
@@ -1159,7 +1160,7 @@ export class Task extends EventEmitter<ClineEvents> {
11591160
throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`)
11601161
}
11611162

1162-
if (this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) {
1163+
if (this.consecutiveMistakeLimit > 0 && this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) {
11631164
const { response, text, images } = await this.ask(
11641165
"mistake_limit_reached",
11651166
t("common:errors.mistake_limit_guidance"),

src/core/task/__tests__/Task.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,70 @@ describe("Cline", () => {
320320
expect(cline.diffStrategy).toBeDefined()
321321
})
322322

323+
it("should use default consecutiveMistakeLimit when not provided", () => {
324+
const cline = new Task({
325+
provider: mockProvider,
326+
apiConfiguration: mockApiConfig,
327+
task: "test task",
328+
startTask: false,
329+
})
330+
331+
expect(cline.consecutiveMistakeLimit).toBe(3)
332+
})
333+
334+
it("should respect provided consecutiveMistakeLimit", () => {
335+
const cline = new Task({
336+
provider: mockProvider,
337+
apiConfiguration: mockApiConfig,
338+
consecutiveMistakeLimit: 5,
339+
task: "test task",
340+
startTask: false,
341+
})
342+
343+
expect(cline.consecutiveMistakeLimit).toBe(5)
344+
})
345+
346+
it("should keep consecutiveMistakeLimit of 0 as 0 for unlimited", () => {
347+
const cline = new Task({
348+
provider: mockProvider,
349+
apiConfiguration: mockApiConfig,
350+
consecutiveMistakeLimit: 0,
351+
task: "test task",
352+
startTask: false,
353+
})
354+
355+
expect(cline.consecutiveMistakeLimit).toBe(0)
356+
})
357+
358+
it("should pass 0 to ToolRepetitionDetector for unlimited mode", () => {
359+
const cline = new Task({
360+
provider: mockProvider,
361+
apiConfiguration: mockApiConfig,
362+
consecutiveMistakeLimit: 0,
363+
task: "test task",
364+
startTask: false,
365+
})
366+
367+
// The toolRepetitionDetector should be initialized with 0 for unlimited mode
368+
expect(cline.toolRepetitionDetector).toBeDefined()
369+
// Verify the limit remains as 0
370+
expect(cline.consecutiveMistakeLimit).toBe(0)
371+
})
372+
373+
it("should pass consecutiveMistakeLimit to ToolRepetitionDetector", () => {
374+
const cline = new Task({
375+
provider: mockProvider,
376+
apiConfiguration: mockApiConfig,
377+
consecutiveMistakeLimit: 5,
378+
task: "test task",
379+
startTask: false,
380+
})
381+
382+
// The toolRepetitionDetector should be initialized with the same limit
383+
expect(cline.toolRepetitionDetector).toBeDefined()
384+
expect(cline.consecutiveMistakeLimit).toBe(5)
385+
})
386+
323387
it("should require either task or historyItem", () => {
324388
expect(() => {
325389
new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })

src/core/tools/ToolRepetitionDetector.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,11 @@ export class ToolRepetitionDetector {
4343
this.previousToolCallJson = currentToolCallJson
4444
}
4545

46-
// Check if limit is reached
47-
if (this.consecutiveIdenticalToolCallCount >= this.consecutiveIdenticalToolCallLimit) {
46+
// Check if limit is reached (0 means unlimited)
47+
if (
48+
this.consecutiveIdenticalToolCallLimit > 0 &&
49+
this.consecutiveIdenticalToolCallCount >= this.consecutiveIdenticalToolCallLimit
50+
) {
4851
// Reset counters to allow recovery if user guides the AI past this point
4952
this.consecutiveIdenticalToolCallCount = 0
5053
this.previousToolCallJson = null

src/core/tools/__tests__/ToolRepetitionDetector.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,5 +301,61 @@ describe("ToolRepetitionDetector", () => {
301301
expect(result3.allowExecution).toBe(false)
302302
expect(result3.askUser).toBeDefined()
303303
})
304+
305+
it("should never block when limit is 0 (unlimited)", () => {
306+
const detector = new ToolRepetitionDetector(0)
307+
308+
// Try many identical calls
309+
for (let i = 0; i < 10; i++) {
310+
const result = detector.check(createToolUse("tool", "tool-name"))
311+
expect(result.allowExecution).toBe(true)
312+
expect(result.askUser).toBeUndefined()
313+
}
314+
})
315+
316+
it("should handle different limits correctly", () => {
317+
// Test with limit of 5
318+
const detector5 = new ToolRepetitionDetector(5)
319+
const tool = createToolUse("tool", "tool-name")
320+
321+
// First 4 calls should be allowed
322+
for (let i = 0; i < 4; i++) {
323+
const result = detector5.check(tool)
324+
expect(result.allowExecution).toBe(true)
325+
expect(result.askUser).toBeUndefined()
326+
}
327+
328+
// 5th call should be blocked
329+
const result5 = detector5.check(tool)
330+
expect(result5.allowExecution).toBe(false)
331+
expect(result5.askUser).toBeDefined()
332+
expect(result5.askUser?.messageKey).toBe("mistake_limit_reached")
333+
})
334+
335+
it("should reset counter after blocking and allow new attempts", () => {
336+
const detector = new ToolRepetitionDetector(2)
337+
const tool = createToolUse("tool", "tool-name")
338+
339+
// First call allowed
340+
expect(detector.check(tool).allowExecution).toBe(true)
341+
342+
// Second call should block (limit is 2)
343+
const blocked = detector.check(tool)
344+
expect(blocked.allowExecution).toBe(false)
345+
346+
// After blocking, counter should reset and allow new attempts
347+
expect(detector.check(tool).allowExecution).toBe(true)
348+
})
349+
350+
it("should handle negative limits as 0 (unlimited)", () => {
351+
const detector = new ToolRepetitionDetector(-1)
352+
353+
// Should behave like unlimited
354+
for (let i = 0; i < 5; i++) {
355+
const result = detector.check(createToolUse("tool", "tool-name"))
356+
expect(result.allowExecution).toBe(true)
357+
expect(result.askUser).toBeUndefined()
358+
}
359+
})
304360
})
305361
})

src/core/webview/ClineProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,7 @@ export class ClineProvider
553553
enableDiff,
554554
enableCheckpoints,
555555
fuzzyMatchThreshold,
556+
consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit,
556557
task,
557558
images,
558559
experiments,
@@ -589,6 +590,7 @@ export class ClineProvider
589590
enableDiff,
590591
enableCheckpoints,
591592
fuzzyMatchThreshold,
593+
consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit,
592594
historyItem,
593595
experiments,
594596
rootTask: historyItem.rootTask,

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
66
import {
77
type ProviderName,
88
type ProviderSettings,
9+
DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
910
openRouterDefaultModelId,
1011
requestyDefaultModelId,
1112
glamaDefaultModelId,
@@ -64,6 +65,7 @@ import { ThinkingBudget } from "./ThinkingBudget"
6465
import { DiffSettingsControl } from "./DiffSettingsControl"
6566
import { TemperatureControl } from "./TemperatureControl"
6667
import { RateLimitSecondsControl } from "./RateLimitSecondsControl"
68+
import { ConsecutiveMistakeLimitControl } from "./ConsecutiveMistakeLimitControl"
6769
import { BedrockCustomArn } from "./providers/BedrockCustomArn"
6870
import { buildDocLink } from "@src/utils/docLinks"
6971

@@ -547,6 +549,14 @@ const ApiOptions = ({
547549
value={apiConfiguration.rateLimitSeconds || 0}
548550
onChange={(value) => setApiConfigurationField("rateLimitSeconds", value)}
549551
/>
552+
<ConsecutiveMistakeLimitControl
553+
value={
554+
apiConfiguration.consecutiveMistakeLimit !== undefined
555+
? apiConfiguration.consecutiveMistakeLimit
556+
: DEFAULT_CONSECUTIVE_MISTAKE_LIMIT
557+
}
558+
onChange={(value) => setApiConfigurationField("consecutiveMistakeLimit", value)}
559+
/>
550560
</>
551561
)}
552562
</div>

0 commit comments

Comments
 (0)