From 1b37dec9827da46dc94bced498820547375842eb Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Sun, 28 Jun 2026 19:53:17 +0000 Subject: [PATCH] feat: add tiered tool-repetition detection (soft warning + hard stop) Replace the single-threshold ToolRepetitionDetector with a two-tier system: - Soft block (default 2nd identical call): the tool is not executed and the model receives an error asking it to justify the repeat. The user is not involved and the counter keeps incrementing toward the hard limit. - Hard stop: execution stops and the user is asked for guidance (previous behavior). --- packages/types/src/provider-settings.ts | 8 + packages/types/src/task.ts | 1 + ...resentAssistantMessage-custom-tool.spec.ts | 2 +- .../presentAssistantMessage-images.spec.ts | 2 +- ...ntAssistantMessage-tool-repetition.spec.ts | 261 +++++++++ ...esentAssistantMessage-unknown-tool.spec.ts | 2 +- .../presentAssistantMessage.ts | 13 +- src/core/config/ProviderSettingsManager.ts | 22 + .../__tests__/ProviderSettingsManager.spec.ts | 70 +++ src/core/task/Task.ts | 9 +- src/core/task/__tests__/Task.spec.ts | 38 ++ src/core/tools/ToolRepetitionDetector.ts | 96 +++- .../__tests__/ToolRepetitionDetector.spec.ts | 539 +++++------------- src/core/webview/ClineProvider.ts | 2 + src/i18n/locales/ca/tools.json | 1 + src/i18n/locales/de/tools.json | 1 + src/i18n/locales/en/tools.json | 1 + src/i18n/locales/es/tools.json | 1 + src/i18n/locales/fr/tools.json | 1 + src/i18n/locales/hi/tools.json | 1 + src/i18n/locales/id/tools.json | 1 + src/i18n/locales/it/tools.json | 1 + src/i18n/locales/ja/tools.json | 1 + src/i18n/locales/ko/tools.json | 1 + src/i18n/locales/nl/tools.json | 1 + src/i18n/locales/pl/tools.json | 1 + src/i18n/locales/pt-BR/tools.json | 1 + src/i18n/locales/ru/tools.json | 1 + src/i18n/locales/tr/tools.json | 1 + src/i18n/locales/vi/tools.json | 1 + src/i18n/locales/zh-CN/tools.json | 1 + src/i18n/locales/zh-TW/tools.json | 1 + .../src/components/settings/ApiOptions.tsx | 21 + .../settings/ToolRepetitionLimitControl.tsx | 42 ++ .../ToolRepetitionLimitControl.spec.tsx | 75 +++ .../__tests__/toolRepetitionLimits.spec.ts | 51 ++ .../settings/toolRepetitionLimits.ts | 32 ++ webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 6 +- webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 55 files changed, 959 insertions(+), 419 deletions(-) create mode 100644 src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts create mode 100644 webview-ui/src/components/settings/ToolRepetitionLimitControl.tsx create mode 100644 webview-ui/src/components/settings/__tests__/ToolRepetitionLimitControl.spec.tsx create mode 100644 webview-ui/src/components/settings/__tests__/toolRepetitionLimits.spec.ts create mode 100644 webview-ui/src/components/settings/toolRepetitionLimits.ts diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 26c4dee7e1..9fe0abe9b6 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -30,6 +30,13 @@ import { export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 +/** + * Number of identical consecutive tool calls allowed before the tool is + * soft-blocked. When reached, the tool is not executed and the model is + * asked to justify why it needs to repeat the call. Set to 0 to disable. + */ +export const DEFAULT_TOOL_REPETITION_SOFT_LIMIT = 2 + /** * DynamicProvider * @@ -191,6 +198,7 @@ const baseProviderSettingsSchema = z.object({ modelTemperature: z.number().nullish(), rateLimitSeconds: z.number().optional(), consecutiveMistakeLimit: z.number().min(0).optional(), + toolRepetitionSoftLimit: z.number().min(0).optional(), // Model reasoning. enableReasoningEffort: z.boolean().optional(), diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 7447dc772e..25a1f6ba2a 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -87,6 +87,7 @@ export interface CreateTaskOptions { taskId?: string enableCheckpoints?: boolean consecutiveMistakeLimit?: number + toolRepetitionSoftLimit?: number experiments?: Record initialTodos?: TodoItem[] /** Initial status for the task's history item (e.g., "active" for child tasks) */ diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 6675f18ce8..ca4dfbce39 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -63,7 +63,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { recordToolUsage: vi.fn(), recordToolError: vi.fn(), toolRepetitionDetector: { - check: vi.fn().mockReturnValue({ allowExecution: true }), + check: vi.fn().mockReturnValue({ action: "allow" }), }, providerRef: { deref: () => ({ diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts index fcf778b8f8..b77ebbf2a2 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -47,7 +47,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = }, recordToolUsage: vi.fn(), toolRepetitionDetector: { - check: vi.fn().mockReturnValue({ allowExecution: true }), + check: vi.fn().mockReturnValue({ action: "allow" }), }, providerRef: { deref: () => ({ diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts new file mode 100644 index 0000000000..d8f2957017 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts @@ -0,0 +1,261 @@ +// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts + +import { presentAssistantMessage } from "../presentAssistantMessage" + +// Mock dependencies +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn(() => true), +})) + +// Mock the read_file tool so we can assert the normal execution path is taken +// when the repetition detector allows a tool call. The handle spy simulates a +// successful tool run by pushing a tool_result, mirroring the real tool. +// `vi.hoisted` is required because `vi.mock` factories are hoisted above +// top-level variable declarations. +const { readFileHandle } = vi.hoisted(() => ({ + readFileHandle: vi.fn(async (_cline: any, block: any, { pushToolResult }: any) => { + pushToolResult(`[read_file for '${block?.params?.path ?? block?.nativeArgs?.path}'] Result`) + }), +})) + +vi.mock("../../tools/ReadFileTool", () => ({ + readFileTool: { + handle: readFileHandle, + getReadFileToolDescription: vi.fn(() => "[read_file]"), + }, +})) + +const captureConsecutiveMistakeError = vi.fn() +const captureException = vi.fn() +const captureToolUsage = vi.fn() + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + get captureToolUsage() { + return captureToolUsage + }, + get captureConsecutiveMistakeError() { + return captureConsecutiveMistakeError + }, + get captureException() { + return captureException + }, + captureEvent: vi.fn(), + }, + }, +})) + +describe("presentAssistantMessage - Tool Repetition Detection", () => { + let mockTask: any + + beforeEach(() => { + vi.clearAllMocks() + + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + consecutiveMistakeLimit: 5, + clineMessages: [], + apiConfiguration: { apiProvider: "anthropic" }, + api: { + getModel: () => ({ id: "test-model", info: {} }), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ action: "allow" }), + }, + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + } + + mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { + const existingResult = mockTask.userMessageContent.find( + (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) + }) + + it("should soft block without involving the user and return an error to the model", async () => { + const toolCallId = "tool_call_soft_block" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + mockTask.toolRepetitionDetector.check = vi.fn().mockReturnValue({ + action: "soft_block", + message: "The tool 'read_file' was blocked because it was just called with identical parameters.", + }) + + await presentAssistantMessage(mockTask) + + // The user should NOT have been asked anything for a soft block. + expect(mockTask.ask).not.toHaveBeenCalled() + + // A tool_result with the soft block message should have been pushed. + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + expect(toolResult).toBeDefined() + expect(toolResult.content).toContain("read_file") + expect(toolResult.content).toContain("identical parameters") + + // No telemetry escalation for a soft block. + expect(captureConsecutiveMistakeError).not.toHaveBeenCalled() + expect(captureException).not.toHaveBeenCalled() + }) + + it("should hard block, ask the user for guidance, and record telemetry", async () => { + const toolCallId = "tool_call_hard_block" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + mockTask.toolRepetitionDetector.check = vi.fn().mockReturnValue({ + action: "hard_block", + askUser: { + messageKey: "mistake_limit_reached", + messageDetail: "Roo appears to be stuck in a loop calling {toolName} repeatedly.", + }, + }) + + mockTask.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + + await presentAssistantMessage(mockTask) + + // The user must be asked for guidance with the resolved message key. + expect(mockTask.ask).toHaveBeenCalledWith( + "mistake_limit_reached", + expect.stringContaining("read_file"), + ) + + // Telemetry escalation should have fired for a hard block. + expect(captureConsecutiveMistakeError).toHaveBeenCalledWith("test-task-id") + expect(captureException).toHaveBeenCalled() + + // A tool_result describing the repetition limit should have been pushed. + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + expect(toolResult).toBeDefined() + expect(toolResult.content).toContain("read_file") + }) + + it("should incorporate user feedback when the user responds to a hard block", async () => { + const toolCallId = "tool_call_hard_block_feedback" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + mockTask.toolRepetitionDetector.check = vi.fn().mockReturnValue({ + action: "hard_block", + askUser: { + messageKey: "mistake_limit_reached", + messageDetail: "Stuck calling {toolName} repeatedly.", + }, + }) + + mockTask.ask = vi.fn().mockResolvedValue({ + response: "messageResponse", + text: "try a different file", + images: [], + }) + + await presentAssistantMessage(mockTask) + + // User feedback should have been surfaced to the chat. + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "try a different file", []) + + // And appended to the user message content. + const feedbackBlock = mockTask.userMessageContent.find( + (item: any) => item.type === "text" && String(item.text).includes("try a different file"), + ) + expect(feedbackBlock).toBeDefined() + expect(feedbackBlock.text).toContain("Tool repetition limit reached") + }) + + it("should execute the tool normally when the detector allows it", async () => { + const toolCallId = "tool_call_allow" + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: toolCallId, + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + mockTask.toolRepetitionDetector.check = vi.fn().mockReturnValue({ action: "allow" }) + + await presentAssistantMessage(mockTask) + + // The detector should have been consulted. + expect(mockTask.toolRepetitionDetector.check).toHaveBeenCalled() + // No hard block ask should have occurred. + expect(mockTask.ask).not.toHaveBeenCalledWith("mistake_limit_reached", expect.anything()) + + // The tool must have actually continued into normal execution: the + // read_file tool runner should have been dispatched with this block. + expect(readFileHandle).toHaveBeenCalledTimes(1) + const [, dispatchedBlock] = readFileHandle.mock.calls[0] + expect(dispatchedBlock).toMatchObject({ name: "read_file", id: toolCallId }) + + // And the normal execution path should have produced a tool_result + // (no soft/hard block error message). + const toolResult = mockTask.userMessageContent.find( + (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + expect(toolResult).toBeDefined() + expect(toolResult.content).toContain("read_file") + expect(toolResult.is_error).toBeUndefined() + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 8e6c8d9d9e..353b5f7287 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -43,7 +43,7 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { recordToolUsage: vi.fn(), recordToolError: vi.fn(), toolRepetitionDetector: { - check: vi.fn().mockReturnValue({ allowExecution: true }), + check: vi.fn().mockReturnValue({ action: "allow" }), }, providerRef: { deref: () => ({ diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index f71b5cc1bd..bdcf5a90d7 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -629,8 +629,17 @@ export async function presentAssistantMessage(cline: Task) { // block directly. const repetitionCheck = cline.toolRepetitionDetector.check(block) - // If execution is not allowed, notify user and break. - if (!repetitionCheck.allowExecution && repetitionCheck.askUser) { + // Soft block: do NOT involve the user. Return an error to the + // model asking it to justify repeating the call (or try a + // different approach). The detector keeps counting so continued + // repetition will eventually escalate to a hard block. + if (repetitionCheck.action === "soft_block") { + pushToolResult(formatResponse.toolError(repetitionCheck.message)) + break + } + + // Hard block: stop and ask the user for guidance. + if (repetitionCheck.action === "hard_block") { // Handle repetition similar to mistake_limit_reached pattern. const { response, text, images } = await cline.ask( repetitionCheck.askUser.messageKey as ClineAsk, diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 51f79cff35..de381c8f70 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -9,6 +9,7 @@ import { isSecretStateKey, ProviderSettingsEntry, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + DEFAULT_TOOL_REPETITION_SOFT_LIMIT, getModelId, type ProviderName, isProviderName, @@ -43,6 +44,7 @@ export const providerProfilesSchema = z.object({ rateLimitSecondsMigrated: z.boolean().optional(), openAiHeadersMigrated: z.boolean().optional(), consecutiveMistakeLimitMigrated: z.boolean().optional(), + toolRepetitionLimitsMigrated: z.boolean().optional(), todoListEnabledMigrated: z.boolean().optional(), claudeCodeLegacySettingsMigrated: z.boolean().optional(), routerProviderMigrated: z.boolean().optional(), @@ -68,6 +70,7 @@ export class ProviderSettingsManager { rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs openAiHeadersMigrated: true, // Mark as migrated on fresh installs consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs + toolRepetitionLimitsMigrated: true, // Mark as migrated on fresh installs todoListEnabledMigrated: true, // Mark as migrated on fresh installs claudeCodeLegacySettingsMigrated: true, // Mark as migrated on fresh installs routerProviderMigrated: true, // Mark as migrated on fresh installs @@ -174,6 +177,12 @@ export class ProviderSettingsManager { isDirty = true } + if (!providerProfiles.migrations.toolRepetitionLimitsMigrated) { + await this.migrateToolRepetitionLimits(providerProfiles) + providerProfiles.migrations.toolRepetitionLimitsMigrated = true + isDirty = true + } + if (!providerProfiles.migrations.todoListEnabledMigrated) { await this.migrateTodoListEnabled(providerProfiles) providerProfiles.migrations.todoListEnabledMigrated = true @@ -271,6 +280,19 @@ export class ProviderSettingsManager { } } + private async migrateToolRepetitionLimits(providerProfiles: ProviderProfiles) { + try { + for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { + // Default the soft warning threshold. + if (apiConfig.toolRepetitionSoftLimit == null) { + apiConfig.toolRepetitionSoftLimit = DEFAULT_TOOL_REPETITION_SOFT_LIMIT + } + } + } catch (error) { + console.error(`[MigrateToolRepetitionLimits] Failed to migrate tool repetition limits:`, error) + } + } + private async migrateTodoListEnabled(providerProfiles: ProviderProfiles) { try { for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index c6bd19c0b1..18e5b616ce 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -91,6 +91,7 @@ describe("ProviderSettingsManager", () => { rateLimitSecondsMigrated: true, openAiHeadersMigrated: true, consecutiveMistakeLimitMigrated: true, + toolRepetitionLimitsMigrated: true, todoListEnabledMigrated: true, claudeCodeLegacySettingsMigrated: true, routerProviderMigrated: true, @@ -211,6 +212,75 @@ describe("ProviderSettingsManager", () => { expect(storedConfig.migrations.consecutiveMistakeLimitMigrated).toEqual(true) }) + it("should call migrateToolRepetitionLimits if it has not done so already", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: { + config: {}, + id: "default", + }, + existing: { + apiProvider: "anthropic", + consecutiveMistakeLimit: 7, + }, + preset: { + apiProvider: "anthropic", + // Pre-existing repetition limits should not be overwritten + toolRepetitionSoftLimit: 1, + }, + }, + migrations: { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + toolRepetitionLimitsMigrated: false, + }, + }), + ) + + await providerSettingsManager.initialize() + + const calls = mockSecrets.store.mock.calls + const storedConfig = JSON.parse(calls[calls.length - 1][1]) + + // Default soft limit applied everywhere it was missing + expect(storedConfig.apiConfigs.default.toolRepetitionSoftLimit).toEqual(2) + expect(storedConfig.apiConfigs.existing.toolRepetitionSoftLimit).toEqual(2) + + // Pre-existing soft limit is not overwritten + expect(storedConfig.apiConfigs.preset.toolRepetitionSoftLimit).toEqual(1) + + expect(storedConfig.migrations.toolRepetitionLimitsMigrated).toEqual(true) + }) + + it("should not throw if migrateToolRepetitionLimits encounters an error", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // A frozen apiConfig causes the property assignment inside the migration + // to throw in strict mode, exercising the catch/error branch. + const frozenConfig = Object.freeze({ apiProvider: "anthropic" }) + const providerProfiles = { + currentApiConfigName: "default", + apiConfigs: { + frozen: frozenConfig, + }, + } as unknown as ProviderProfiles + + // The migration must swallow the error rather than propagate it. + await expect( + (providerSettingsManager as any).migrateToolRepetitionLimits(providerProfiles), + ).resolves.toBeUndefined() + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to migrate tool repetition limits"), + expect.anything(), + ) + + consoleErrorSpy.mockRestore() + }) + it("should call migrateTodoListEnabled if it has not done so already", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6e8d70f349..96c3bc9f79 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -48,6 +48,7 @@ import { isResumableAsk, QueuedMessage, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + DEFAULT_TOOL_REPETITION_SOFT_LIMIT, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, @@ -315,6 +316,7 @@ export class Task extends EventEmitter implements TaskLike { // Tool Use consecutiveMistakeCount: number = 0 consecutiveMistakeLimit: number + toolRepetitionSoftLimit: number consecutiveMistakeCountForApplyDiff: Map = new Map() consecutiveMistakeCountForEditFile: Map = new Map() consecutiveNoToolUseCount: number = 0 @@ -442,6 +444,7 @@ export class Task extends EventEmitter implements TaskLike { enableCheckpoints = true, checkpointTimeout = DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + toolRepetitionSoftLimit = DEFAULT_TOOL_REPETITION_SOFT_LIMIT, taskId, task, images, @@ -510,6 +513,7 @@ export class Task extends EventEmitter implements TaskLike { this.autoApprovalHandler = new AutoApprovalHandler() this.consecutiveMistakeLimit = consecutiveMistakeLimit ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT + this.toolRepetitionSoftLimit = toolRepetitionSoftLimit ?? DEFAULT_TOOL_REPETITION_SOFT_LIMIT this.providerRef = new WeakRef(provider) this.globalStoragePath = provider.context.globalStorageUri.fsPath this.diffViewProvider = new DiffViewProvider(this.cwd, this) @@ -564,7 +568,10 @@ export class Task extends EventEmitter implements TaskLike { // Set up diff strategy this.diffStrategy = new MultiSearchReplaceDiffStrategy(diffFuzzyThreshold) - this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit) + this.toolRepetitionDetector = new ToolRepetitionDetector( + this.toolRepetitionSoftLimit, + this.consecutiveMistakeLimit, + ) // Initialize todo list if provided if (initialTodos && initialTodos.length > 0) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 27ba5ce8ff..8676504f15 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -393,6 +393,44 @@ describe("Cline", () => { expect(cline.consecutiveMistakeLimit).toBe(5) }) + it("should default tool repetition soft limit when not provided", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + expect(cline.toolRepetitionSoftLimit).toBe(2) + }) + + it("should respect provided tool repetition soft limit", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + toolRepetitionSoftLimit: 3, + task: "test task", + startTask: false, + }) + + expect(cline.toolRepetitionSoftLimit).toBe(3) + }) + + it("should derive the tool repetition hard stop from consecutiveMistakeLimit", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + consecutiveMistakeLimit: 7, + toolRepetitionSoftLimit: 2, + task: "test task", + startTask: false, + }) + + expect(cline.consecutiveMistakeLimit).toBe(7) + expect(cline.toolRepetitionSoftLimit).toBe(2) + expect(cline.toolRepetitionDetector).toBeDefined() + }) + it("should require either task or historyItem", () => { expect(() => { new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) diff --git a/src/core/tools/ToolRepetitionDetector.ts b/src/core/tools/ToolRepetitionDetector.ts index 27592c5210..03828b5ce1 100644 --- a/src/core/tools/ToolRepetitionDetector.ts +++ b/src/core/tools/ToolRepetitionDetector.ts @@ -2,37 +2,71 @@ import stringify from "safe-stable-stringify" import { ToolUse } from "../../shared/tools" import { t } from "../../i18n" +/** + * Result of a repetition check. + * + * - `allow`: the tool may be executed normally. + * - `soft_block`: the tool is NOT executed; an error message is returned to the + * model asking it to justify repeating the call. The user is NOT involved. + * The internal counter keeps incrementing so continued repetition eventually + * escalates to a `hard_block`. + * - `hard_block`: execution is stopped and the user is asked for guidance. + */ +export type RepetitionCheckResult = + | { action: "allow" } + | { action: "soft_block"; message: string } + | { + action: "hard_block" + askUser: { + messageKey: string + messageDetail: string + } + } + /** * Class for detecting consecutive identical tool calls * to prevent the AI from getting stuck in a loop. + * + * Uses a two-tier system: + * 1. Soft warning: after `softWarningLimit` identical consecutive calls, the + * tool is blocked and the model is asked to justify the repeat (no user + * involvement). + * 2. Hard stop: after `hardStopLimit` identical consecutive calls, execution + * stops and the user is asked for guidance. This limit is supplied by the + * caller from `consecutiveMistakeLimit`, so the existing consecutive mistake + * limit also governs repeated identical tool calls. */ export class ToolRepetitionDetector { private previousToolCallJson: string | null = null private consecutiveIdenticalToolCallCount: number = 0 - private readonly consecutiveIdenticalToolCallLimit: number + private readonly softWarningLimit: number + private readonly hardStopLimit: number /** - * Creates a new ToolRepetitionDetector - * @param limit The maximum number of identical consecutive tool calls allowed + * Creates a new ToolRepetitionDetector. + * + * @param softLimit The number of identical consecutive tool calls allowed + * before soft-blocking (asking the model to justify). 0 disables soft + * blocking. + * @param hardLimit The hard stop limit, supplied by the caller from + * `consecutiveMistakeLimit`. The number of identical consecutive tool calls + * allowed before hard-stopping (asking the user). 0 disables hard stopping. */ - constructor(limit: number = 3) { - this.consecutiveIdenticalToolCallLimit = limit + constructor(softLimit: number = 2, hardLimit: number = 5) { + // Treat negative values as 0 (unlimited / disabled). + this.softWarningLimit = Math.max(0, softLimit) + this.hardStopLimit = Math.max(0, hardLimit) } /** * Checks if the current tool call is identical to the previous one - * and determines if execution should be allowed + * and determines if execution should be allowed, soft-blocked, or + * hard-blocked. * * @param currentToolCallBlock ToolUse object representing the current tool call - * @returns Object indicating if execution is allowed and a message to show if not + * @returns A RepetitionCheckResult describing how the caller should proceed. */ - public check(currentToolCallBlock: ToolUse): { - allowExecution: boolean - askUser?: { - messageKey: string - messageDetail: string - } - } { + public check(currentToolCallBlock: ToolUse): RepetitionCheckResult { // Serialize the block to a canonical JSON string for comparison const currentToolCallJson = this.serializeToolUse(currentToolCallBlock) @@ -44,18 +78,14 @@ export class ToolRepetitionDetector { this.previousToolCallJson = currentToolCallJson } - // Check if limit is reached (0 means unlimited) - if ( - this.consecutiveIdenticalToolCallLimit > 0 && - this.consecutiveIdenticalToolCallCount >= this.consecutiveIdenticalToolCallLimit - ) { + // Hard stop check (0 means unlimited / disabled). + // Checked first so it always takes precedence over the soft warning. + if (this.hardStopLimit > 0 && this.consecutiveIdenticalToolCallCount >= this.hardStopLimit) { // Reset counters to allow recovery if user guides the AI past this point - this.consecutiveIdenticalToolCallCount = 0 - this.previousToolCallJson = null + this.reset() - // Return result indicating execution should not be allowed return { - allowExecution: false, + action: "hard_block", askUser: { messageKey: "mistake_limit_reached", messageDetail: t("tools:toolRepetitionLimitReached", { toolName: currentToolCallBlock.name }), @@ -63,8 +93,26 @@ export class ToolRepetitionDetector { } } + // Soft warning check (0 means unlimited / disabled). + // Do NOT reset the counter here so continued repetition escalates to a + // hard stop. + if (this.softWarningLimit > 0 && this.consecutiveIdenticalToolCallCount >= this.softWarningLimit) { + return { + action: "soft_block", + message: t("tools:toolRepetitionSoftBlock", { toolName: currentToolCallBlock.name }), + } + } + // Execution is allowed - return { allowExecution: true } + return { action: "allow" } + } + + /** + * Resets the internal repetition tracking state. + */ + private reset(): void { + this.consecutiveIdenticalToolCallCount = 0 + this.previousToolCallJson = null } /** diff --git a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts index 815037fafd..507fecce04 100644 --- a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts +++ b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts @@ -8,10 +8,12 @@ import { ToolRepetitionDetector } from "../ToolRepetitionDetector" vitest.mock("../../../i18n", () => ({ t: vitest.fn(function (key, options) { - // For toolRepetitionLimitReached key, return a message with the tool name. if (key === "tools:toolRepetitionLimitReached" && options?.toolName) { return `Roo appears to be stuck in a loop, attempting the same action (${options.toolName}) repeatedly. This might indicate a problem with its current strategy.` } + if (key === "tools:toolRepetitionSoftBlock" && options?.toolName) { + return `The tool '${options.toolName}' was blocked because it was just called with identical parameters. Explain why the repeated call is necessary.` + } return key }), })) @@ -28,42 +30,36 @@ function createToolUse(name: string, displayName?: string, params: Record { // ===== Initialization tests ===== describe("initialization", () => { - it("should default to a limit of 3 if no argument provided", () => { + it("should default to soft limit 2 and hard limit 5 when no arguments provided", () => { const detector = new ToolRepetitionDetector() - // We'll verify this through behavior in subsequent tests - - // First call (counter = 0) - const result1 = detector.check(createToolUse("test", "test-tool")) - expect(result1.allowExecution).toBe(true) - - // Second identical call (counter = 1) - const result2 = detector.check(createToolUse("test", "test-tool")) - expect(result2.allowExecution).toBe(true) - - // Third identical call (counter = 2) - const result3 = detector.check(createToolUse("test", "test-tool")) - expect(result3.allowExecution).toBe(true) - - // Fourth identical call (counter = 3) reaches the default limit - const result4 = detector.check(createToolUse("test", "test-tool")) - expect(result4.allowExecution).toBe(false) + const tool = createToolUse("test", "test-tool") + + // Call 1 (count = 0) -> allow + expect(detector.check(tool).action).toBe("allow") + // Call 2 (count = 1) -> allow + expect(detector.check(tool).action).toBe("allow") + // Call 3 (count = 2) -> soft_block (reaches soft limit 2) + expect(detector.check(tool).action).toBe("soft_block") + // Call 4 (count = 3) -> soft_block + expect(detector.check(tool).action).toBe("soft_block") + // Call 5 (count = 4) -> soft_block + expect(detector.check(tool).action).toBe("soft_block") + // Call 6 (count = 5) -> hard_block (reaches hard limit 5) + expect(detector.check(tool).action).toBe("hard_block") }) - it("should use the custom limit when provided", () => { - const customLimit = 2 - const detector = new ToolRepetitionDetector(customLimit) - - // First call (counter = 0) - const result1 = detector.check(createToolUse("test", "test-tool")) - expect(result1.allowExecution).toBe(true) - - // Second identical call (counter = 1) - const result2 = detector.check(createToolUse("test", "test-tool")) - expect(result2.allowExecution).toBe(true) - - // Third identical call (counter = 2) reaches the custom limit - const result3 = detector.check(createToolUse("test", "test-tool")) - expect(result3.allowExecution).toBe(false) + it("should use the custom limits when provided", () => { + const detector = new ToolRepetitionDetector(1, 3) + const tool = createToolUse("test", "test-tool") + + // Call 1 (count = 0) -> allow + expect(detector.check(tool).action).toBe("allow") + // Call 2 (count = 1) -> soft_block (reaches soft limit 1) + expect(detector.check(tool).action).toBe("soft_block") + // Call 3 (count = 2) -> soft_block + expect(detector.check(tool).action).toBe("soft_block") + // Call 4 (count = 3) -> hard_block (reaches hard limit 3) + expect(detector.check(tool).action).toBe("hard_block") }) }) @@ -72,412 +68,195 @@ describe("ToolRepetitionDetector", () => { it("should allow execution for different tool calls", () => { const detector = new ToolRepetitionDetector() - const result1 = detector.check(createToolUse("first", "first-tool")) - expect(result1.allowExecution).toBe(true) - expect(result1.askUser).toBeUndefined() - - const result2 = detector.check(createToolUse("second", "second-tool")) - expect(result2.allowExecution).toBe(true) - expect(result2.askUser).toBeUndefined() - - const result3 = detector.check(createToolUse("third", "third-tool")) - expect(result3.allowExecution).toBe(true) - expect(result3.askUser).toBeUndefined() + expect(detector.check(createToolUse("first", "first-tool")).action).toBe("allow") + expect(detector.check(createToolUse("second", "second-tool")).action).toBe("allow") + expect(detector.check(createToolUse("third", "third-tool")).action).toBe("allow") }) it("should reset the counter when different tool calls are made", () => { - const detector = new ToolRepetitionDetector(2) - - // First call - detector.check(createToolUse("same", "same-tool")) + const detector = new ToolRepetitionDetector(1, 2) - // Second identical call would reach limit of 2, but we'll make a different call - detector.check(createToolUse("different", "different-tool")) + // First call to "same" (count = 0) -> allow + expect(detector.check(createToolUse("same", "same-tool")).action).toBe("allow") - // Back to the first tool - should be allowed since counter was reset - const result = detector.check(createToolUse("same", "same-tool")) - expect(result.allowExecution).toBe(true) - }) - }) - - // ===== Repetition Below Limit tests ===== - describe("repetition below limit", () => { - it("should allow execution when repetition is below limit and block when limit reached", () => { - const detector = new ToolRepetitionDetector(3) - - // First call (counter = 0) - const result1 = detector.check(createToolUse("repeat", "repeat-tool")) - expect(result1.allowExecution).toBe(true) - - // Second identical call (counter = 1) - const result2 = detector.check(createToolUse("repeat", "repeat-tool")) - expect(result2.allowExecution).toBe(true) - - // Third identical call (counter = 2) - const result3 = detector.check(createToolUse("repeat", "repeat-tool")) - expect(result3.allowExecution).toBe(true) + // Different tool resets the counter (count = 0) -> allow + expect(detector.check(createToolUse("different", "different-tool")).action).toBe("allow") - // Fourth identical call (counter = 3) reaches limit - const result4 = detector.check(createToolUse("repeat", "repeat-tool")) - expect(result4.allowExecution).toBe(false) + // Back to first tool - counter was reset (count = 0) -> allow + expect(detector.check(createToolUse("same", "same-tool")).action).toBe("allow") }) }) - // ===== Repetition Reaches Limit tests ===== - describe("repetition reaches limit", () => { - it("should block execution when repetition reaches the limit", () => { - const detector = new ToolRepetitionDetector(3) + // ===== Soft block tests ===== + describe("soft block behavior", () => { + it("should soft block at the soft limit and include a message with the tool name", () => { + const detector = new ToolRepetitionDetector(2, 5) + const tool = createToolUse("repeat", "repeat-tool") - // First call (counter = 0) - detector.check(createToolUse("repeat", "repeat-tool")) - - // Second identical call (counter = 1) - detector.check(createToolUse("repeat", "repeat-tool")) - - // Third identical call (counter = 2) - detector.check(createToolUse("repeat", "repeat-tool")) - - // Fourth identical call (counter = 3) - should reach limit - const result = detector.check(createToolUse("repeat", "repeat-tool")) - - expect(result.allowExecution).toBe(false) - expect(result.askUser).toBeDefined() - expect(result.askUser?.messageKey).toBe("mistake_limit_reached") - expect(result.askUser?.messageDetail).toContain("repeat-tool") - }) + expect(detector.check(tool).action).toBe("allow") + expect(detector.check(tool).action).toBe("allow") - it("should reset internal state after limit is reached", () => { - const detector = new ToolRepetitionDetector(2) - - // Reach the limit - detector.check(createToolUse("repeat", "repeat-tool")) - detector.check(createToolUse("repeat", "repeat-tool")) - const limitResult = detector.check(createToolUse("repeat", "repeat-tool")) // This reaches limit - expect(limitResult.allowExecution).toBe(false) - - // Use a new tool call - should be allowed since state was reset - const result = detector.check(createToolUse("new", "new-tool")) - expect(result.allowExecution).toBe(true) + const result = detector.check(tool) + expect(result.action).toBe("soft_block") + if (result.action === "soft_block") { + expect(result.message).toContain("repeat-tool") + } }) - }) - - // ===== Repetition After Limit (Post-Reset) tests ===== - describe("repetition after limit", () => { - it("should allow execution of previously problematic tool after reset", () => { - const detector = new ToolRepetitionDetector(2) - // Reach the limit with a specific tool - detector.check(createToolUse("problem", "problem-tool")) - detector.check(createToolUse("problem", "problem-tool")) - const limitResult = detector.check(createToolUse("problem", "problem-tool")) // This reaches limit - expect(limitResult.allowExecution).toBe(false) + it("should keep counting through soft blocks toward the hard limit (does not reset)", () => { + const detector = new ToolRepetitionDetector(2, 4) + const tool = createToolUse("repeat", "repeat-tool") - // The same tool that previously caused problems should now be allowed - const result = detector.check(createToolUse("problem", "problem-tool")) - expect(result.allowExecution).toBe(true) + expect(detector.check(tool).action).toBe("allow") // count 0 + expect(detector.check(tool).action).toBe("allow") // count 1 + expect(detector.check(tool).action).toBe("soft_block") // count 2 + expect(detector.check(tool).action).toBe("soft_block") // count 3 + expect(detector.check(tool).action).toBe("hard_block") // count 4 -> hard }) - it("should require reaching the limit again after reset", () => { - const detector = new ToolRepetitionDetector(2) - - // Reach the limit - detector.check(createToolUse("repeat", "repeat-tool")) - detector.check(createToolUse("repeat", "repeat-tool")) - const limitResult = detector.check(createToolUse("repeat", "repeat-tool")) // This reaches limit - expect(limitResult.allowExecution).toBe(false) + it("should not soft block when soft limit is 0 (disabled) but still hard block", () => { + const detector = new ToolRepetitionDetector(0, 3) + const tool = createToolUse("repeat", "repeat-tool") - // First call after reset - detector.check(createToolUse("repeat", "repeat-tool")) - - // Second call after reset - detector.check(createToolUse("repeat", "repeat-tool")) - - // Third identical call (counter = 2) should reach limit again - const result = detector.check(createToolUse("repeat", "repeat-tool")) - expect(result.allowExecution).toBe(false) - expect(result.askUser).toBeDefined() + expect(detector.check(tool).action).toBe("allow") // count 0 + expect(detector.check(tool).action).toBe("allow") // count 1 + expect(detector.check(tool).action).toBe("allow") // count 2 + expect(detector.check(tool).action).toBe("hard_block") // count 3 }) }) - // ===== Tool Name Interpolation tests ===== - describe("tool name interpolation", () => { - it("should include tool name in the error message", () => { - const detector = new ToolRepetitionDetector(2) - const toolName = "special-tool-name" - - // Reach the limit - detector.check(createToolUse("test", toolName)) - detector.check(createToolUse("test", toolName)) - const result = detector.check(createToolUse("test", toolName)) - - expect(result.allowExecution).toBe(false) - expect(result.askUser?.messageDetail).toContain(toolName) - }) - }) - - // ===== Edge Cases ===== - describe("edge cases", () => { - it("should handle empty tool call", () => { - const detector = new ToolRepetitionDetector(2) - - // Create an empty tool call - a tool with no parameters - // Use the empty tool directly in the check calls - detector.check(createToolUse("empty-tool", "empty-tool")) - detector.check(createToolUse("empty-tool", "empty-tool")) - const result = detector.check(createToolUse("empty-tool", "empty-tool")) - - expect(result.allowExecution).toBe(false) - expect(result.askUser).toBeDefined() - }) - - it("should handle different tool names with identical serialized JSON", () => { - const detector = new ToolRepetitionDetector(2) - - // First, call with tool-name-1 to set up the counter - const toolUse1 = createToolUse("tool-name-1", "tool-name-1", { param: "value" }) - detector.check(toolUse1) - - // Create a tool that will serialize to the same JSON as toolUse1 - // We need to mock the serializeToolUse method to return the same value - const toolUse2 = createToolUse("tool-name-2", "tool-name-2", { param: "value" }) - - // Override the private method to force identical serialization - const originalSerialize = (detector as any).serializeToolUse - ;(detector as any).serializeToolUse = (tool: ToolUse) => { - // Use string comparison for the name since it's technically an enum - if (String(tool.name) === "tool-name-2") { - return originalSerialize.call(detector, toolUse1) // Return the same JSON as toolUse1 - } - return originalSerialize.call(detector, tool) + // ===== Hard block tests ===== + describe("hard block behavior", () => { + it("should hard block at the hard limit with askUser details", () => { + const detector = new ToolRepetitionDetector(2, 3) + const tool = createToolUse("repeat", "repeat-tool") + + detector.check(tool) // count 0 + detector.check(tool) // count 1 (soft) + detector.check(tool) // count 2 (soft) + const result = detector.check(tool) // count 3 -> hard + + expect(result.action).toBe("hard_block") + if (result.action === "hard_block") { + expect(result.askUser.messageKey).toBe("mistake_limit_reached") + expect(result.askUser.messageDetail).toContain("repeat-tool") } - - // Second call - this should be considered identical due to our mock - const result2 = detector.check(toolUse2) - expect(result2.allowExecution).toBe(true) // Still allowed (counter = 1) - - // Third call - should be blocked (limit is 2) - const result3 = detector.check(toolUse2) - - // Restore the original method - ;(detector as any).serializeToolUse = originalSerialize - - // Since we're directly manipulating the internal state for testing, - // we expect it to consider this a repetition - expect(result3.allowExecution).toBe(false) - expect(result3.askUser).toBeDefined() }) - it("should treat tools with same parameters in different order as identical", () => { - const detector = new ToolRepetitionDetector(2) - - // First call with parameters in one order - const toolUse1 = createToolUse("same-tool", "same-tool", { a: "1", b: "2", c: "3" }) - detector.check(toolUse1) - - // Second call with same parameters but in different order - const toolUse2 = createToolUse("same-tool", "same-tool", { c: "3", a: "1", b: "2" }) - detector.check(toolUse2) - - // Third call - should be blocked (limit is 2) - const toolUse3 = createToolUse("same-tool", "same-tool", { b: "2", c: "3", a: "1" }) - const result = detector.check(toolUse3) - - // Since parameters are sorted alphabetically in the serialized JSON, - // these should be considered identical - expect(result.allowExecution).toBe(false) - expect(result.askUser).toBeDefined() - }) - }) - - // ===== Explicit Nth Call Blocking tests ===== - describe("explicit Nth call blocking behavior", () => { - it("should allow the 1st call but block on the 2nd call for limit 1", () => { - const detector = new ToolRepetitionDetector(1) - - // First call (counter = 0) should be allowed - const result1 = detector.check(createToolUse("tool", "tool-name")) - expect(result1.allowExecution).toBe(true) - expect(result1.askUser).toBeUndefined() - - // Second identical call (counter = 1) should be blocked - const result2 = detector.check(createToolUse("tool", "tool-name")) - expect(result2.allowExecution).toBe(false) - expect(result2.askUser).toBeDefined() - }) - - it("should allow first 2 calls but block on the 3rd call for limit 2", () => { - const detector = new ToolRepetitionDetector(2) - - // First call (counter = 0) - const result1 = detector.check(createToolUse("tool", "tool-name")) - expect(result1.allowExecution).toBe(true) - - // Second identical call (counter = 1) - const result2 = detector.check(createToolUse("tool", "tool-name")) - expect(result2.allowExecution).toBe(true) - - // Third identical call (counter = 2) should be blocked - const result3 = detector.check(createToolUse("tool", "tool-name")) - expect(result3.allowExecution).toBe(false) - expect(result3.askUser).toBeDefined() - }) - - it("should allow first 3 calls but block on the 4th call for limit 3 (default)", () => { - const detector = new ToolRepetitionDetector(3) - - // First call (counter = 0) - const result1 = detector.check(createToolUse("tool", "tool-name")) - expect(result1.allowExecution).toBe(true) - - // Second identical call (counter = 1) - const result2 = detector.check(createToolUse("tool", "tool-name")) - expect(result2.allowExecution).toBe(true) + it("should reset internal state after a hard block", () => { + const detector = new ToolRepetitionDetector(2, 2) + const tool = createToolUse("repeat", "repeat-tool") - // Third identical call (counter = 2) - const result3 = detector.check(createToolUse("tool", "tool-name")) - expect(result3.allowExecution).toBe(true) + detector.check(tool) // count 0 + const limitResult = detector.check(tool) // count 1 -> soft? No: soft=2, hard=2 + // With soft=2 hard=2, hard takes precedence at count 2. + expect(limitResult.action).toBe("allow") + const hard = detector.check(tool) // count 2 -> hard + expect(hard.action).toBe("hard_block") - // Fourth identical call (counter = 3) should be blocked - const result4 = detector.check(createToolUse("tool", "tool-name")) - expect(result4.allowExecution).toBe(false) - expect(result4.askUser).toBeDefined() + // After hard block, state resets - a new identical call is allowed again + expect(detector.check(tool).action).toBe("allow") }) - it("should never block when limit is 0 (unlimited)", () => { - const detector = new ToolRepetitionDetector(0) + it("should not hard block when hard limit is 0 (disabled) but still soft block", () => { + const detector = new ToolRepetitionDetector(2, 0) + const tool = createToolUse("repeat", "repeat-tool") - // Try many identical calls + expect(detector.check(tool).action).toBe("allow") // count 0 + expect(detector.check(tool).action).toBe("allow") // count 1 + // Many repeats only ever soft block for (let i = 0; i < 10; i++) { - const result = detector.check(createToolUse("tool", "tool-name")) - expect(result.allowExecution).toBe(true) - expect(result.askUser).toBeUndefined() + expect(detector.check(tool).action).toBe("soft_block") } }) + }) - it("should handle different limits correctly", () => { - // Test with limit of 5 - const detector5 = new ToolRepetitionDetector(5) + // ===== Unlimited (both 0) ===== + describe("unlimited mode", () => { + it("should never block when both limits are 0", () => { + const detector = new ToolRepetitionDetector(0, 0) const tool = createToolUse("tool", "tool-name") - // First 5 calls should be allowed - for (let i = 0; i < 5; i++) { - const result = detector5.check(tool) - expect(result.allowExecution).toBe(true) - expect(result.askUser).toBeUndefined() + for (let i = 0; i < 20; i++) { + expect(detector.check(tool).action).toBe("allow") } - - // 6th call should be blocked - const result6 = detector5.check(tool) - expect(result6.allowExecution).toBe(false) - expect(result6.askUser).toBeDefined() - expect(result6.askUser?.messageKey).toBe("mistake_limit_reached") }) - it("should reset counter after blocking and allow new attempts", () => { - const detector = new ToolRepetitionDetector(2) + it("should treat negative limits as 0 (unlimited)", () => { + const detector = new ToolRepetitionDetector(-1, -5) const tool = createToolUse("tool", "tool-name") - // First call allowed - expect(detector.check(tool).allowExecution).toBe(true) - - // Second call allowed - expect(detector.check(tool).allowExecution).toBe(true) - - // Third call should block (limit is 2) - const blocked = detector.check(tool) - expect(blocked.allowExecution).toBe(false) - - // After blocking, counter should reset and allow new attempts - expect(detector.check(tool).allowExecution).toBe(true) + for (let i = 0; i < 10; i++) { + expect(detector.check(tool).action).toBe("allow") + } }) + }) - it("should handle negative limits as 0 (unlimited)", () => { - const detector = new ToolRepetitionDetector(-1) + // ===== Edge Cases ===== + describe("edge cases", () => { + it("should treat tools with same parameters in different order as identical", () => { + const detector = new ToolRepetitionDetector(2, 5) - // Should behave like unlimited - for (let i = 0; i < 5; i++) { - const result = detector.check(createToolUse("tool", "tool-name")) - expect(result.allowExecution).toBe(true) - expect(result.askUser).toBeUndefined() - } + detector.check(createToolUse("same-tool", "same-tool", { a: "1", b: "2", c: "3" })) + detector.check(createToolUse("same-tool", "same-tool", { c: "3", a: "1", b: "2" })) + const result = detector.check(createToolUse("same-tool", "same-tool", { b: "2", c: "3", a: "1" })) + + // Sorted keys mean these are identical, reaching the soft limit (2) + expect(result.action).toBe("soft_block") }) }) // ===== Native Protocol (nativeArgs) tests ===== describe("native protocol with nativeArgs", () => { it("should differentiate read_file calls with different files in nativeArgs", () => { - const detector = new ToolRepetitionDetector(2) + const detector = new ToolRepetitionDetector(2, 5) - // Create read_file tool use with nativeArgs (like native protocol does) const readFile1: ToolUse = { type: "tool_use", name: "read_file" as ToolName, - params: {}, // Empty for native protocol + params: {}, partial: false, - nativeArgs: { - path: "file1.ts", - }, + nativeArgs: { path: "file1.ts" }, } const readFile2: ToolUse = { type: "tool_use", name: "read_file" as ToolName, - params: {}, // Empty for native protocol + params: {}, partial: false, - nativeArgs: { - path: "file2.ts", - }, + nativeArgs: { path: "file2.ts" }, } - // First call with file1 - expect(detector.check(readFile1).allowExecution).toBe(true) - - // Second call with file2 - should be treated as different - expect(detector.check(readFile2).allowExecution).toBe(true) - - // Third call with file1 again - should reset counter - expect(detector.check(readFile1).allowExecution).toBe(true) + expect(detector.check(readFile1).action).toBe("allow") + expect(detector.check(readFile2).action).toBe("allow") + expect(detector.check(readFile1).action).toBe("allow") }) it("should detect repetition when same files are read multiple times with nativeArgs", () => { - const detector = new ToolRepetitionDetector(2) + const detector = new ToolRepetitionDetector(2, 5) - // Create identical read_file tool uses const readFile: ToolUse = { type: "tool_use", name: "read_file" as ToolName, - params: {}, // Empty for native protocol + params: {}, partial: false, - nativeArgs: { - path: "same-file.ts", - }, + nativeArgs: { path: "same-file.ts" }, } - // First call allowed - expect(detector.check(readFile).allowExecution).toBe(true) - - // Second call allowed - expect(detector.check(readFile).allowExecution).toBe(true) - - // Third identical call should be blocked (limit is 2) - const result = detector.check(readFile) - expect(result.allowExecution).toBe(false) - expect(result.askUser).toBeDefined() + expect(detector.check(readFile).action).toBe("allow") + expect(detector.check(readFile).action).toBe("allow") + expect(detector.check(readFile).action).toBe("soft_block") }) it("should treat different slice offsets as distinct read_file calls", () => { - const detector = new ToolRepetitionDetector(2) + const detector = new ToolRepetitionDetector(2, 5) const readFile1: ToolUse = { type: "tool_use", name: "read_file" as ToolName, params: {}, partial: false, - nativeArgs: { - path: "a.ts", - offset: 1, - limit: 2000, - }, + nativeArgs: { path: "a.ts", offset: 1, limit: 2000 }, } const readFile2: ToolUse = { @@ -485,30 +264,22 @@ describe("ToolRepetitionDetector", () => { name: "read_file" as ToolName, params: {}, partial: false, - nativeArgs: { - path: "a.ts", - offset: 2001, - limit: 2000, - }, + nativeArgs: { path: "a.ts", offset: 2001, limit: 2000 }, } - // Different offsets should be treated as different calls - expect(detector.check(readFile1).allowExecution).toBe(true) - expect(detector.check(readFile2).allowExecution).toBe(true) + expect(detector.check(readFile1).action).toBe("allow") + expect(detector.check(readFile2).action).toBe("allow") }) it("should handle tools with both params and nativeArgs", () => { - const detector = new ToolRepetitionDetector(2) + const detector = new ToolRepetitionDetector(2, 5) const tool1: ToolUse = { type: "tool_use", name: "execute_command" as ToolName, params: { command: "ls" }, partial: false, - nativeArgs: { - command: "ls", - cwd: "/home/user", - }, + nativeArgs: { command: "ls", cwd: "/home/user" }, } const tool2: ToolUse = { @@ -516,29 +287,21 @@ describe("ToolRepetitionDetector", () => { name: "execute_command" as ToolName, params: { command: "ls" }, partial: false, - nativeArgs: { - command: "ls", - cwd: "/home/admin", - }, + nativeArgs: { command: "ls", cwd: "/home/admin" }, } - // Different cwd in nativeArgs should make these different - expect(detector.check(tool1).allowExecution).toBe(true) - expect(detector.check(tool2).allowExecution).toBe(true) + expect(detector.check(tool1).action).toBe("allow") + expect(detector.check(tool2).action).toBe("allow") }) it("should handle tools with only params (no nativeArgs)", () => { - const detector = new ToolRepetitionDetector(2) + const detector = new ToolRepetitionDetector(2, 5) const legacyTool = createToolUse("read_file", "read_file", { path: "test.txt" }) - // Should work the same as before - expect(detector.check(legacyTool).allowExecution).toBe(true) - expect(detector.check(legacyTool).allowExecution).toBe(true) - - const result = detector.check(legacyTool) - expect(result.allowExecution).toBe(false) - expect(result.askUser).toBeDefined() + expect(detector.check(legacyTool).action).toBe("allow") + expect(detector.check(legacyTool).action).toBe("allow") + expect(detector.check(legacyTool).action).toBe("soft_block") }) }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e19d84cb0b..12160de166 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1096,6 +1096,7 @@ export class ClineProvider enableCheckpoints, checkpointTimeout, consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, + toolRepetitionSoftLimit: apiConfiguration.toolRepetitionSoftLimit, historyItem, experiments, rootTask: historyItem.rootTask, @@ -3086,6 +3087,7 @@ export class ClineProvider enableCheckpoints, checkpointTimeout, consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, + toolRepetitionSoftLimit: apiConfiguration.toolRepetitionSoftLimit, task: text, images, experiments, diff --git a/src/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index 223264252e..27fa802b19 100644 --- a/src/i18n/locales/ca/tools.json +++ b/src/i18n/locales/ca/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Fitxer d'imatge ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo sembla estar atrapat en un bucle, intentant la mateixa acció ({{toolName}}) repetidament. Això podria indicar un problema amb la seva estratègia actual. Considera reformular la tasca, proporcionar instruccions més específiques o guiar-lo cap a un enfocament diferent.", + "toolRepetitionSoftBlock": "L'eina '{{toolName}}' s'ha bloquejat perquè s'acaba de cridar amb paràmetres idèntics. Repetir exactament la mateixa crida difícilment produirà un resultat diferent. Si realment necessites cridar-la de nou, explica primer per què la crida repetida és necessària i què esperes que sigui diferent aquesta vegada. Si no, prova un enfocament diferent.", "unknownToolError": "Zoo ha intentat utilitzar una eina desconeguda: \"{{toolName}}\". Reintentant...", "codebaseSearch": { "approval": "Cercant '{{query}}' a la base de codi..." diff --git a/src/i18n/locales/de/tools.json b/src/i18n/locales/de/tools.json index d04362f2d9..858a9ff359 100644 --- a/src/i18n/locales/de/tools.json +++ b/src/i18n/locales/de/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Bilddatei ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo scheint in einer Schleife festzustecken und versucht wiederholt dieselbe Aktion ({{toolName}}). Dies könnte auf ein Problem mit der aktuellen Strategie hindeuten. Überlege dir, die Aufgabe umzuformulieren, genauere Anweisungen zu geben oder Zoo zu einem anderen Ansatz zu führen.", + "toolRepetitionSoftBlock": "Das Tool '{{toolName}}' wurde blockiert, da es soeben mit identischen Parametern aufgerufen wurde. Den exakt gleichen Aufruf zu wiederholen führt wahrscheinlich nicht zu einem anderen Ergebnis. Wenn du es wirklich erneut aufrufen musst, erkläre zuerst, warum der wiederholte Aufruf notwendig ist und was du dieses Mal anders erwartest. Andernfalls versuche einen anderen Ansatz.", "unknownToolError": "Zoo hat versucht, ein unbekanntes Tool zu verwenden: \"{{toolName}}\". Wiederhole Versuch...", "codebaseSearch": { "approval": "Suche nach '{{query}}' im Codebase..." diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index 9c08368e53..4693894b3a 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Image file ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo appears to be stuck in a loop, attempting the same action ({{toolName}}) repeatedly. This might indicate a problem with its current strategy. Consider rephrasing the task, providing more specific instructions, or guiding it towards a different approach.", + "toolRepetitionSoftBlock": "The tool '{{toolName}}' was blocked because it was just called with identical parameters. Repeating the exact same call is unlikely to produce a different result. If you genuinely need to call it again, first explain why the repeated call is necessary and what you expect to be different this time. Otherwise, try a different approach.", "unknownToolError": "Zoo tried to use an unknown tool: \"{{toolName}}\". Retrying...", "codebaseSearch": { "approval": "Searching for '{{query}}' in codebase..." diff --git a/src/i18n/locales/es/tools.json b/src/i18n/locales/es/tools.json index f865a8e484..c3d306f2c9 100644 --- a/src/i18n/locales/es/tools.json +++ b/src/i18n/locales/es/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Archivo de imagen ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo parece estar atrapado en un bucle, intentando la misma acción ({{toolName}}) repetidamente. Esto podría indicar un problema con su estrategia actual. Considera reformular la tarea, proporcionar instrucciones más específicas o guiarlo hacia un enfoque diferente.", + "toolRepetitionSoftBlock": "La herramienta '{{toolName}}' fue bloqueada porque acaba de ser llamada con parámetros idénticos. Repetir exactamente la misma llamada difícilmente producirá un resultado diferente. Si realmente necesitas llamarla de nuevo, primero explica por qué la llamada repetida es necesaria y qué esperas que sea diferente esta vez. De lo contrario, prueba un enfoque diferente.", "unknownToolError": "Zoo intentó usar una herramienta desconocida: \"{{toolName}}\". Reintentando...", "codebaseSearch": { "approval": "Buscando '{{query}}' en la base de código..." diff --git a/src/i18n/locales/fr/tools.json b/src/i18n/locales/fr/tools.json index 7dd65facc4..d59aacff02 100644 --- a/src/i18n/locales/fr/tools.json +++ b/src/i18n/locales/fr/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Fichier image ({{size}} Ko)" }, "toolRepetitionLimitReached": "Zoo semble être bloqué dans une boucle, tentant la même action ({{toolName}}) de façon répétée. Cela pourrait indiquer un problème avec sa stratégie actuelle. Envisage de reformuler la tâche, de fournir des instructions plus spécifiques ou de le guider vers une approche différente.", + "toolRepetitionSoftBlock": "L'outil '{{toolName}}' a été bloqué car il vient d'être appelé avec des paramètres identiques. Répéter exactement le même appel ne produira probablement pas un résultat différent. Si tu as vraiment besoin de l'appeler à nouveau, explique d'abord pourquoi l'appel répété est nécessaire et ce que tu attends de différent cette fois-ci. Sinon, essaie une approche différente.", "unknownToolError": "Zoo a tenté d'utiliser un outil inconnu : \"{{toolName}}\". Nouvelle tentative...", "codebaseSearch": { "approval": "Recherche de '{{query}}' dans la base de code..." diff --git a/src/i18n/locales/hi/tools.json b/src/i18n/locales/hi/tools.json index 04193d45a0..33e3ca6e97 100644 --- a/src/i18n/locales/hi/tools.json +++ b/src/i18n/locales/hi/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "छवि फ़ाइल ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo एक लूप में फंसा हुआ लगता है, बार-बार एक ही क्रिया ({{toolName}}) को दोहरा रहा है। यह उसकी वर्तमान रणनीति में किसी समस्या का संकेत हो सकता है। कार्य को पुनः परिभाषित करने, अधिक विशिष्ट निर्देश देने, या उसे एक अलग दृष्टिकोण की ओर मार्गदर्शित करने पर विचार करें।", + "toolRepetitionSoftBlock": "टूल '{{toolName}}' को ब्लॉक कर दिया गया क्योंकि इसे अभी समान पैरामीटर के साथ बुलाया गया था। बिल्कुल वही कॉल दोहराने से अलग परिणाम मिलने की संभावना कम है। यदि आपको वास्तव में इसे फिर से बुलाने की आवश्यकता है, तो पहले बताएं कि दोहराई गई कॉल क्यों आवश्यक है और इस बार आप क्या अलग होने की उम्मीद करते हैं। अन्यथा, एक अलग दृष्टिकोण आज़माएं।", "unknownToolError": "Zoo ने एक अज्ञात उपकरण का उपयोग करने का प्रयास किया: \"{{toolName}}\"। पुनः प्रयास कर रहा है...", "codebaseSearch": { "approval": "कोडबेस में '{{query}}' खोज रहा है..." diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index b6b64fdcae..e38ffaae40 100644 --- a/src/i18n/locales/id/tools.json +++ b/src/i18n/locales/id/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "File gambar ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo tampaknya terjebak dalam loop, mencoba aksi yang sama ({{toolName}}) berulang kali. Ini mungkin menunjukkan masalah dengan strategi saat ini. Pertimbangkan untuk mengubah frasa tugas, memberikan instruksi yang lebih spesifik, atau mengarahkannya ke pendekatan yang berbeda.", + "toolRepetitionSoftBlock": "Alat '{{toolName}}' diblokir karena baru saja dipanggil dengan parameter yang identik. Mengulangi panggilan yang sama persis kemungkinan tidak akan menghasilkan hasil yang berbeda. Jika kamu benar-benar perlu memanggilnya lagi, jelaskan terlebih dahulu mengapa panggilan berulang diperlukan dan apa yang kamu harapkan berbeda kali ini. Jika tidak, coba pendekatan yang berbeda.", "unknownToolError": "Zoo mencoba menggunakan alat yang tidak dikenal: \"{{toolName}}\". Mencoba lagi...", "codebaseSearch": { "approval": "Mencari '{{query}}' di codebase..." diff --git a/src/i18n/locales/it/tools.json b/src/i18n/locales/it/tools.json index 97d851bd7f..ee01ce1573 100644 --- a/src/i18n/locales/it/tools.json +++ b/src/i18n/locales/it/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "File immagine ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo sembra essere bloccato in un ciclo, tentando ripetutamente la stessa azione ({{toolName}}). Questo potrebbe indicare un problema con la sua strategia attuale. Considera di riformulare l'attività, fornire istruzioni più specifiche o guidarlo verso un approccio diverso.", + "toolRepetitionSoftBlock": "Lo strumento '{{toolName}}' è stato bloccato perché è appena stato chiamato con parametri identici. Ripetere esattamente la stessa chiamata difficilmente produrrà un risultato diverso. Se hai davvero bisogno di chiamarlo di nuovo, spiega prima perché la chiamata ripetuta è necessaria e cosa ti aspetti che sia diverso questa volta. Altrimenti, prova un approccio diverso.", "unknownToolError": "Zoo ha provato ad utilizzare uno strumento sconosciuto: \"{{toolName}}\". Nuovo tentativo...", "codebaseSearch": { "approval": "Ricerca di '{{query}}' nella base di codice..." diff --git a/src/i18n/locales/ja/tools.json b/src/i18n/locales/ja/tools.json index 3987b7b745..9ba22cbad7 100644 --- a/src/i18n/locales/ja/tools.json +++ b/src/i18n/locales/ja/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "画像ファイル({{size}} KB)" }, "toolRepetitionLimitReached": "Zooが同じ操作({{toolName}})を繰り返し試みるループに陥っているようです。これは現在の方法に問題がある可能性を示しています。タスクの言い換え、より具体的な指示の提供、または別のアプローチへの誘導を検討してください。", + "toolRepetitionSoftBlock": "ツール '{{toolName}}' は、同一のパラメータで呼び出されたばかりのためブロックされました。まったく同じ呼び出しを繰り返しても、異なる結果が得られる可能性は低いです。本当に再度呼び出す必要がある場合は、まずなぜ繰り返しの呼び出しが必要なのか、今回は何が異なると期待しているのかを説明してください。そうでなければ、別のアプローチを試してください。", "unknownToolError": "Zooが不明なツールを使用しようとしました:「{{toolName}}」。再試行中...", "codebaseSearch": { "approval": "コードベースで '{{query}}' を検索中..." diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index cc65e2a80b..3cc186c0d5 100644 --- a/src/i18n/locales/ko/tools.json +++ b/src/i18n/locales/ko/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "이미지 파일 ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo가 같은 동작({{toolName}})을 반복적으로 시도하면서 루프에 갇힌 것 같습니다. 이는 현재 전략에 문제가 있을 수 있음을 나타냅니다. 작업을 다시 표현하거나, 더 구체적인 지침을 제공하거나, 다른 접근 방식으로 안내해 보세요.", + "toolRepetitionSoftBlock": "도구 '{{toolName}}'이(가) 방금 동일한 매개변수로 호출되었기 때문에 차단되었습니다. 완전히 동일한 호출을 반복해도 다른 결과가 나올 가능성은 낮습니다. 정말로 다시 호출해야 한다면, 먼저 반복 호출이 필요한 이유와 이번에는 무엇이 달라질 것으로 기대하는지 설명하세요. 그렇지 않으면 다른 접근 방식을 시도하세요.", "unknownToolError": "Zoo가 알 수 없는 도구를 사용하려고 했습니다: \"{{toolName}}\". 다시 시도 중...", "codebaseSearch": { "approval": "코드베이스에서 '{{query}}' 검색 중..." diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index d157275a7a..49560a5f1b 100644 --- a/src/i18n/locales/nl/tools.json +++ b/src/i18n/locales/nl/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Afbeeldingsbestand ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo lijkt vast te zitten in een lus, waarbij hij herhaaldelijk dezelfde actie ({{toolName}}) probeert. Dit kan duiden op een probleem met de huidige strategie. Overweeg de taak te herformuleren, specifiekere instructies te geven of Zoo naar een andere aanpak te leiden.", + "toolRepetitionSoftBlock": "De tool '{{toolName}}' is geblokkeerd omdat deze zojuist met identieke parameters is aangeroepen. Exact dezelfde aanroep herhalen levert waarschijnlijk geen ander resultaat op. Als je deze echt opnieuw moet aanroepen, leg dan eerst uit waarom de herhaalde aanroep nodig is en wat je deze keer anders verwacht. Probeer anders een andere aanpak.", "unknownToolError": "Zoo probeerde een onbekende tool te gebruiken: \"{{toolName}}\". Opnieuw proberen...", "codebaseSearch": { "approval": "Zoeken naar '{{query}}' in codebase..." diff --git a/src/i18n/locales/pl/tools.json b/src/i18n/locales/pl/tools.json index fce837aafb..968e9a465a 100644 --- a/src/i18n/locales/pl/tools.json +++ b/src/i18n/locales/pl/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Plik obrazu ({{size}} KB)" }, "toolRepetitionLimitReached": "Wygląda na to, że Zoo utknął w pętli, wielokrotnie próbując wykonać tę samą akcję ({{toolName}}). Może to wskazywać na problem z jego obecną strategią. Rozważ przeformułowanie zadania, podanie bardziej szczegółowych instrukcji lub nakierowanie go na inne podejście.", + "toolRepetitionSoftBlock": "Narzędzie '{{toolName}}' zostało zablokowane, ponieważ właśnie zostało wywołane z identycznymi parametrami. Powtarzanie dokładnie tego samego wywołania prawdopodobnie nie da innego wyniku. Jeśli naprawdę musisz wywołać je ponownie, najpierw wyjaśnij, dlaczego powtórne wywołanie jest konieczne i czego oczekujesz, że tym razem będzie inne. W przeciwnym razie spróbuj innego podejścia.", "unknownToolError": "Zoo próbował użyć nieznanego narzędzia: \"{{toolName}}\". Ponowna próba...", "codebaseSearch": { "approval": "Wyszukiwanie '{{query}}' w bazie kodu..." diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index 6505067607..14a2cd7cbf 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Arquivo de imagem ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo parece estar preso em um loop, tentando a mesma ação ({{toolName}}) repetidamente. Isso pode indicar um problema com sua estratégia atual. Considere reformular a tarefa, fornecer instruções mais específicas ou guiá-lo para uma abordagem diferente.", + "toolRepetitionSoftBlock": "A ferramenta '{{toolName}}' foi bloqueada porque acabou de ser chamada com parâmetros idênticos. Repetir exatamente a mesma chamada dificilmente produzirá um resultado diferente. Se você realmente precisa chamá-la novamente, primeiro explique por que a chamada repetida é necessária e o que você espera que seja diferente desta vez. Caso contrário, tente uma abordagem diferente.", "unknownToolError": "Zoo tentou usar uma ferramenta desconhecida: \"{{toolName}}\". Tentando novamente...", "codebaseSearch": { "approval": "Pesquisando '{{query}}' na base de código..." diff --git a/src/i18n/locales/ru/tools.json b/src/i18n/locales/ru/tools.json index 6fecfc272b..a7ca222691 100644 --- a/src/i18n/locales/ru/tools.json +++ b/src/i18n/locales/ru/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Файл изображения ({{size}} КБ)" }, "toolRepetitionLimitReached": "Похоже, что Zoo застрял в цикле, многократно пытаясь выполнить одно и то же действие ({{toolName}}). Это может указывать на проблему с его текущей стратегией. Попробуйте переформулировать задачу, предоставить более конкретные инструкции или направить его к другому подходу.", + "toolRepetitionSoftBlock": "Инструмент '{{toolName}}' заблокирован, так как он только что был вызван с идентичными параметрами. Повторение точно такого же вызова вряд ли даст другой результат. Если вам действительно нужно вызвать его снова, сначала объясните, почему повторный вызов необходим и что, по вашему мнению, будет иначе на этот раз. В противном случае попробуйте другой подход.", "unknownToolError": "Zoo попытался использовать неизвестный инструмент: \"{{toolName}}\". Повторная попытка...", "codebaseSearch": { "approval": "Поиск '{{query}}' в кодовой базе..." diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index d2a79a9fab..29a6a511af 100644 --- a/src/i18n/locales/tr/tools.json +++ b/src/i18n/locales/tr/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Görüntü dosyası ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo bir döngüye takılmış gibi görünüyor, aynı eylemi ({{toolName}}) tekrar tekrar deniyor. Bu, mevcut stratejisinde bir sorun olduğunu gösterebilir. Görevi yeniden ifade etmeyi, daha spesifik talimatlar vermeyi veya onu farklı bir yaklaşıma yönlendirmeyi düşünün.", + "toolRepetitionSoftBlock": "'{{toolName}}' aracı, az önce aynı parametrelerle çağrıldığı için engellendi. Tamamen aynı çağrıyı tekrarlamak büyük olasılıkla farklı bir sonuç vermeyecektir. Onu gerçekten tekrar çağırmanız gerekiyorsa, önce tekrarlanan çağrının neden gerekli olduğunu ve bu sefer neyin farklı olmasını beklediğinizi açıklayın. Aksi takdirde farklı bir yaklaşım deneyin.", "unknownToolError": "Zoo bilinmeyen bir araç kullanmaya çalıştı: \"{{toolName}}\". Yeniden deneniyor...", "codebaseSearch": { "approval": "Kod tabanında '{{query}}' aranıyor..." diff --git a/src/i18n/locales/vi/tools.json b/src/i18n/locales/vi/tools.json index 96c0174317..010e9e7ad7 100644 --- a/src/i18n/locales/vi/tools.json +++ b/src/i18n/locales/vi/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "Tệp hình ảnh ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo dường như đang bị mắc kẹt trong một vòng lặp, liên tục cố gắng thực hiện cùng một hành động ({{toolName}}). Điều này có thể cho thấy vấn đề với chiến lược hiện tại. Hãy cân nhắc việc diễn đạt lại nhiệm vụ, cung cấp hướng dẫn cụ thể hơn, hoặc hướng Zoo theo một cách tiếp cận khác.", + "toolRepetitionSoftBlock": "Công cụ '{{toolName}}' đã bị chặn vì vừa được gọi với các tham số giống hệt nhau. Lặp lại cùng một lệnh gọi y hệt khó có thể tạo ra kết quả khác. Nếu bạn thực sự cần gọi lại, trước tiên hãy giải thích tại sao việc gọi lại là cần thiết và bạn mong đợi điều gì khác lần này. Nếu không, hãy thử một cách tiếp cận khác.", "unknownToolError": "Zoo đã cố gắng sử dụng một công cụ không xác định: \"{{toolName}}\". Đang thử lại...", "codebaseSearch": { "approval": "Đang tìm kiếm '{{query}}' trong cơ sở mã..." diff --git a/src/i18n/locales/zh-CN/tools.json b/src/i18n/locales/zh-CN/tools.json index 9a16e15ac5..8500164ee2 100644 --- a/src/i18n/locales/zh-CN/tools.json +++ b/src/i18n/locales/zh-CN/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "图片文件 ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo 似乎陷入循环,反复尝试同一操作 ({{toolName}})。这可能表明当前策略存在问题。请考虑重新描述任务、提供更具体的指示或引导其尝试不同的方法。", + "toolRepetitionSoftBlock": "工具 '{{toolName}}' 已被阻止,因为它刚刚以完全相同的参数被调用。重复完全相同的调用不太可能产生不同的结果。如果你确实需要再次调用它,请先说明为什么需要重复调用,以及这次你期望有什么不同。否则,请尝试不同的方法。", "unknownToolError": "Zoo 尝试使用未知工具:\"{{toolName}}\"。正在重试...", "codebaseSearch": { "approval": "正在搜索代码库中的 '{{query}}'..." diff --git a/src/i18n/locales/zh-TW/tools.json b/src/i18n/locales/zh-TW/tools.json index 0d35605211..0cb5acb868 100644 --- a/src/i18n/locales/zh-TW/tools.json +++ b/src/i18n/locales/zh-TW/tools.json @@ -7,6 +7,7 @@ "imageWithSize": "圖片檔案 ({{size}} KB)" }, "toolRepetitionLimitReached": "Zoo 似乎陷入循環,反覆嘗試同一操作 ({{toolName}})。這可能表明目前策略存在問題。請考慮重新描述工作、提供更具體的指示或引導其嘗試不同的方法。", + "toolRepetitionSoftBlock": "工具 '{{toolName}}' 已被封鎖,因為它剛剛以完全相同的參數被呼叫。重複完全相同的呼叫不太可能產生不同的結果。如果你確實需要再次呼叫它,請先說明為什麼需要重複呼叫,以及這次你期望有什麼不同。否則,請嘗試不同的方法。", "unknownToolError": "Zoo 嘗試使用未知工具:「{{toolName}}」。正在重試...", "codebaseSearch": { "approval": "正在搜尋程式碼庫中的「{{query}}」..." diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index d54e0b634e..f8b64582d3 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -9,6 +9,7 @@ import { type ProviderSettings, isRetiredProvider, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + DEFAULT_TOOL_REPETITION_SOFT_LIMIT, } from "@roo-code/types" import { @@ -88,6 +89,8 @@ import { TodoListSettingsControl } from "./TodoListSettingsControl" import { TemperatureControl } from "./TemperatureControl" import { RateLimitSecondsControl } from "./RateLimitSecondsControl" import { ConsecutiveMistakeLimitControl } from "./ConsecutiveMistakeLimitControl" +import { ToolRepetitionLimitControl } from "./ToolRepetitionLimitControl" +import { clampToolRepetitionSoftLimit } from "./toolRepetitionLimits" import { BedrockCustomArn } from "./providers/BedrockCustomArn" import { buildDocLink } from "@src/utils/docLinks" import { BookOpenText } from "lucide-react" @@ -758,6 +761,24 @@ const ApiOptions = ({ } onChange={(value) => setApiConfigurationField("consecutiveMistakeLimit", value)} /> + + setApiConfigurationField( + "toolRepetitionSoftLimit", + clampToolRepetitionSoftLimit( + value, + apiConfiguration.consecutiveMistakeLimit !== undefined + ? apiConfiguration.consecutiveMistakeLimit + : DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + ), + ) + } + /> {selectedProvider === "poe" && ( void +} + +export const ToolRepetitionLimitControl = ({ + softValue, + onSoftChange, +}: ToolRepetitionLimitControlProps) => { + const { t } = useAppTranslation() + + const resolvedSoft = softValue ?? DEFAULT_TOOL_REPETITION_SOFT_LIMIT + + return ( +
+
+ +
+ onSoftChange(Math.max(0, newValue[0]))} + /> + {Math.max(0, resolvedSoft)} +
+
+ {t("settings:providers.toolRepetitionSoftLimit.description")} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/ToolRepetitionLimitControl.spec.tsx b/webview-ui/src/components/settings/__tests__/ToolRepetitionLimitControl.spec.tsx new file mode 100644 index 0000000000..25c060139d --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ToolRepetitionLimitControl.spec.tsx @@ -0,0 +1,75 @@ +// npx vitest src/components/settings/__tests__/ToolRepetitionLimitControl.spec.tsx + +import { render, screen, fireEvent } from "@testing-library/react" + +import { ToolRepetitionLimitControl } from "../ToolRepetitionLimitControl" + +// Mock the translation hook +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "settings:providers.toolRepetitionSoftLimit.label": "Tool repetition soft limit", + "settings:providers.toolRepetitionSoftLimit.description": + "Number of identical consecutive tool calls allowed before Roo is asked to justify repeating.", + } + return translations[key] || key + }, + }), +})) + +// Mock the Slider so we can drive onValueChange from a range input. +vi.mock("@/components/ui", () => ({ + Slider: ({ value, onValueChange }: any) => ( + onValueChange([parseInt(e.target.value, 10)])} + /> + ), +})) + +describe("ToolRepetitionLimitControl", () => { + it("renders the label, description, and current soft value", () => { + const onSoftChange = vi.fn() + render() + + expect(screen.getByText("Tool repetition soft limit")).toBeInTheDocument() + expect(screen.getByText(/Number of identical consecutive tool calls/)).toBeInTheDocument() + + const slider = screen.getByRole("slider") + expect(slider).toHaveValue("3") + // The numeric value is displayed next to the slider. + expect(screen.getByText("3")).toBeInTheDocument() + }) + + it("falls back to the default soft limit when softValue is undefined", () => { + const onSoftChange = vi.fn() + render() + + const slider = screen.getByRole("slider") + // DEFAULT_TOOL_REPETITION_SOFT_LIMIT is 2. + expect(slider).toHaveValue("2") + }) + + it("calls onSoftChange when the slider value changes", () => { + const onSoftChange = vi.fn() + render() + + const slider = screen.getByRole("slider") + fireEvent.change(slider, { target: { value: "5" } }) + + expect(onSoftChange).toHaveBeenCalledWith(5) + }) + + it("clamps negative slider values to 0", () => { + const onSoftChange = vi.fn() + render() + + const slider = screen.getByRole("slider") + fireEvent.change(slider, { target: { value: "-3" } }) + + expect(onSoftChange).toHaveBeenCalledWith(0) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/toolRepetitionLimits.spec.ts b/webview-ui/src/components/settings/__tests__/toolRepetitionLimits.spec.ts new file mode 100644 index 0000000000..0e9d7b216e --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/toolRepetitionLimits.spec.ts @@ -0,0 +1,51 @@ +// npx vitest src/components/settings/__tests__/toolRepetitionLimits.spec.ts + +import { clampToolRepetitionSoftLimit } from "../toolRepetitionLimits" + +describe("clampToolRepetitionSoftLimit", () => { + it("keeps a soft limit that is already below the hard limit", () => { + expect(clampToolRepetitionSoftLimit(2, 5)).toBe(2) + }) + + it("clamps a soft limit equal to the hard limit down to hardLimit - 1 (invalid combination)", () => { + // Soft == hard would make the soft-block path unreachable. + expect(clampToolRepetitionSoftLimit(5, 5)).toBe(4) + }) + + it("clamps a soft limit above the hard limit down to hardLimit - 1 (invalid combination)", () => { + expect(clampToolRepetitionSoftLimit(8, 3)).toBe(2) + }) + + it("allows soft limit 0 (soft warnings disabled) regardless of hard limit", () => { + expect(clampToolRepetitionSoftLimit(0, 5)).toBe(0) + }) + + it("clamps soft to 0 when the hard limit is 1 (no room below it)", () => { + expect(clampToolRepetitionSoftLimit(3, 1)).toBe(0) + }) + + it("does not impose an upper bound when the hard stop is disabled (hard limit 0)", () => { + expect(clampToolRepetitionSoftLimit(9, 0)).toBe(9) + }) + + it("clamps negative soft values to 0", () => { + expect(clampToolRepetitionSoftLimit(-3, 5)).toBe(0) + expect(clampToolRepetitionSoftLimit(-3, 0)).toBe(0) + }) + + it("never returns a negative value for a fractional positive hard limit", () => { + // hardLimit - 1 would be negative (e.g. 0.5 - 1 = -0.5); must be clamped to 0. + expect(clampToolRepetitionSoftLimit(3, 0.5)).toBe(0) + expect(clampToolRepetitionSoftLimit(0, 0.5)).toBe(0) + }) + + it("guarantees the saved value is always strictly below an enabled hard limit", () => { + for (let hard = 1; hard <= 20; hard++) { + for (let requested = 0; requested <= 30; requested++) { + const result = clampToolRepetitionSoftLimit(requested, hard) + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThan(hard) + } + } + }) +}) diff --git a/webview-ui/src/components/settings/toolRepetitionLimits.ts b/webview-ui/src/components/settings/toolRepetitionLimits.ts new file mode 100644 index 0000000000..3ba4a427aa --- /dev/null +++ b/webview-ui/src/components/settings/toolRepetitionLimits.ts @@ -0,0 +1,32 @@ +/** + * Clamps the tool-repetition soft limit so that it always stays strictly below + * the hard stop limit (the consecutive mistake limit). + * + * The detector in `ToolRepetitionDetector.check()` checks the hard limit first + * and the soft limit second. If the soft limit is greater than or equal to the + * hard limit, the soft-block path becomes unreachable (the hard block always + * fires first). To keep the soft warning meaningful, the soft limit must be + * strictly less than the hard limit. + * + * Special cases: + * - A hard limit of 0 means the hard stop is disabled (unlimited). In that case + * the soft limit has no upper bound and is only clamped to be non-negative. + * - Negative inputs are clamped to 0. + * + * @param softValue The requested soft limit value. + * @param hardLimit The hard stop limit (consecutive mistake limit). + * @returns The soft limit clamped to `[0, hardLimit - 1]` (or `[0, ∞)` when the + * hard limit is 0/disabled). + */ +export function clampToolRepetitionSoftLimit(softValue: number, hardLimit: number): number { + const soft = Math.max(0, softValue) + + // Hard stop disabled (unlimited) -> no upper bound from the hard limit. + if (hardLimit <= 0) { + return soft + } + + // Soft must stay strictly below the hard stop so the soft-block path is reachable, + // while never dropping below zero (e.g. a fractional hard limit like 0.5). + return Math.max(0, Math.min(soft, hardLimit - 1)) +} diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 779d87b5ee..a3937181b0 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Reintents il·limitats habilitats (procediment automàtic). El diàleg no apareixerà mai.", "warning": "⚠️ Establir a 0 permet reintents il·limitats que poden consumir un ús significatiu de l'API" }, + "toolRepetitionSoftLimit": { + "label": "Llindar d'avís suau de repetició", + "description": "Després d'aquest nombre de crides idèntiques consecutives a l'eina, l'eina es bloqueja i es demana a la IA que justifiqui el reintent, sense implicar-te. Estableix-ho a 0 per desactivar els avisos suaus." + }, "reasoningEffort": { "label": "Esforç de raonament del model", "none": "Cap", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index c4a3d4b257..630ec0dca5 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Unbegrenzte Wiederholungen aktiviert (automatisches Fortfahren). Der Dialog wird niemals angezeigt.", "warning": "⚠️ Das Setzen auf 0 erlaubt unbegrenzte Wiederholungen, was zu erheblichem API-Verbrauch führen kann" }, + "toolRepetitionSoftLimit": { + "label": "Schwellenwert für sanfte Wiederholungswarnung", + "description": "Nach dieser Anzahl identischer aufeinanderfolgender Tool-Aufrufe wird das Tool blockiert und die KI wird gebeten, den erneuten Versuch zu begründen — ohne dich einzubeziehen. Auf 0 setzen, um sanfte Warnungen zu deaktivieren." + }, "reasoningEffort": { "label": "Modell-Denkaufwand", "none": "Keine", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index e5b0bf7cb3..7e0b49819a 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -686,10 +686,14 @@ }, "consecutiveMistakeLimit": { "label": "Error & Repetition Limit", - "description": "Number of consecutive errors or repeated actions before showing 'Zoo is having trouble' dialog. Set to 0 to disable this safety mechanism (it will never trigger).", + "description": "Number of consecutive errors or repeated identical tool calls before showing 'Zoo is having trouble' dialog. Set to 0 to disable this safety mechanism (it will never trigger).", "unlimitedDescription": "Unlimited retries enabled (auto-proceed). The dialog will never appear.", "warning": "⚠️ Setting to 0 allows unlimited retries which may consume significant API usage" }, + "toolRepetitionSoftLimit": { + "label": "Repetition Soft Warning Threshold", + "description": "After this many identical consecutive tool calls, the tool is blocked and the AI is asked to justify retrying — without involving you. Set to 0 to disable soft warnings." + }, "reasoningEffort": { "label": "Model Reasoning Effort", "none": "None", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index bbb9e32f45..18f4be0b0e 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Reintentos ilimitados habilitados (proceder automáticamente). El diálogo nunca aparecerá.", "warning": "⚠️ Establecer en 0 permite reintentos ilimitados que pueden consumir un uso significativo de la API" }, + "toolRepetitionSoftLimit": { + "label": "Umbral de advertencia suave de repetición", + "description": "Después de esta cantidad de llamadas idénticas consecutivas a la herramienta, la herramienta se bloquea y se pide a la IA que justifique el reintento, sin involucrarte. Establece en 0 para desactivar las advertencias suaves." + }, "reasoningEffort": { "label": "Esfuerzo de razonamiento del modelo", "none": "Ninguno", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 844140300c..774324711a 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Réessais illimités activés (poursuite automatique). La boîte de dialogue n'apparaîtra jamais.", "warning": "⚠️ Mettre à 0 autorise des réessais illimités, ce qui peut consommer une utilisation importante de l'API" }, + "toolRepetitionSoftLimit": { + "label": "Seuil d'avertissement de répétition (souple)", + "description": "Après ce nombre d'appels d'outil identiques consécutifs, l'outil est bloqué et il est demandé à l'IA de justifier la nouvelle tentative, sans t'impliquer. Mets à 0 pour désactiver les avertissements souples." + }, "reasoningEffort": { "label": "Effort de raisonnement du modèle", "none": "Aucun", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 9d092f5078..197b507b42 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "असीमित पुनः प्रयास सक्षम (स्वतः आगे बढ़ें)। संवाद कभी नहीं दिखाई देगा।", "warning": "⚠️ 0 पर सेट करने से असीमित पुनः प्रयास की अनुमति मिलती है जिससे महत्वपूर्ण एपीआई उपयोग हो सकता है" }, + "toolRepetitionSoftLimit": { + "label": "पुनरावृत्ति सॉफ्ट चेतावनी सीमा", + "description": "इतनी समान लगातार टूल कॉल के बाद, टूल को ब्लॉक कर दिया जाता है और AI से पुनः प्रयास को उचित ठहराने के लिए कहा जाता है — आपको शामिल किए बिना। सॉफ्ट चेतावनियों को अक्षम करने के लिए 0 पर सेट करें।" + }, "reasoningEffort": { "label": "मॉडल तर्क प्रयास", "none": "कोई नहीं", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 03216a2ff2..35659fdbaf 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Percobaan ulang tak terbatas diaktifkan (lanjut otomatis). Dialog tidak akan pernah muncul.", "warning": "⚠️ Mengatur ke 0 memungkinkan percobaan ulang tak terbatas yang dapat menghabiskan penggunaan API yang signifikan" }, + "toolRepetitionSoftLimit": { + "label": "Ambang Peringatan Lunak Pengulangan", + "description": "Setelah sekian banyak panggilan alat identik berturut-turut, alat diblokir dan AI diminta untuk menjelaskan alasan mencoba lagi, tanpa melibatkanmu. Atur ke 0 untuk menonaktifkan peringatan lunak." + }, "reasoningEffort": { "label": "Upaya Reasoning Model", "none": "Tidak Ada", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index cb0bd254a9..45412eba82 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Tentativi illimitati abilitati (procedi automaticamente). La finestra di dialogo non verrà mai visualizzata.", "warning": "⚠️ L'impostazione a 0 consente tentativi illimitati che possono consumare un notevole utilizzo dell'API" }, + "toolRepetitionSoftLimit": { + "label": "Soglia di avviso ripetizione (soft)", + "description": "Dopo questo numero di chiamate identiche consecutive allo strumento, lo strumento viene bloccato e all'IA viene chiesto di giustificare il nuovo tentativo, senza coinvolgerti. Imposta a 0 per disabilitare gli avvisi soft." + }, "reasoningEffort": { "label": "Sforzo di ragionamento del modello", "none": "Nessuno", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 7aeadb71e3..a9925b7186 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "無制限のリトライが有効です(自動進行)。ダイアログは表示されません。", "warning": "⚠️ 0に設定すると無制限のリトライが可能になり、API使用量が大幅に増加する可能性があります" }, + "toolRepetitionSoftLimit": { + "label": "繰り返しソフト警告のしきい値", + "description": "同一の連続ツール呼び出しがこの回数に達すると、ツールがブロックされ、AIに再試行の理由を説明するよう求めます(あなたの介入は不要)。0に設定するとソフト警告が無効になります。" + }, "reasoningEffort": { "label": "モデル推論の労力", "none": "なし", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index cafcfaaa81..ea15191eaf 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "무제한 재시도 활성화 (자동 진행). 대화 상자가 나타나지 않습니다.", "warning": "⚠️ 0으로 설정하면 무제한 재시도가 허용되어 상당한 API 사용량이 발생할 수 있습니다" }, + "toolRepetitionSoftLimit": { + "label": "반복 소프트 경고 임계값", + "description": "동일한 연속 도구 호출이 이 횟수에 도달하면 도구가 차단되고 사용자를 개입시키지 않고 AI에게 재시도 사유를 묻습니다. 0으로 설정하면 소프트 경고가 비활성화됩니다." + }, "reasoningEffort": { "label": "모델 추론 노력", "none": "없음", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index fae892592c..90782e4ec4 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Onbeperkt aantal nieuwe pogingen ingeschakeld (automatisch doorgaan). Het dialoogvenster zal nooit verschijnen.", "warning": "⚠️ Instellen op 0 staat onbeperkte nieuwe pogingen toe, wat aanzienlijk API-gebruik kan verbruiken" }, + "toolRepetitionSoftLimit": { + "label": "Drempel zachte herhalingswaarschuwing", + "description": "Na dit aantal identieke opeenvolgende toolaanroepen wordt de tool geblokkeerd en wordt de AI gevraagd het opnieuw proberen te rechtvaardigen, zonder jou erbij te betrekken. Stel in op 0 om zachte waarschuwingen uit te schakelen." + }, "reasoningEffort": { "label": "Model redeneervermogen", "none": "Geen", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 27b90e98d5..fd5481d9ad 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Włączono nieograniczone próby (automatyczne kontynuowanie). Okno dialogowe nigdy się nie pojawi.", "warning": "⚠️ Ustawienie na 0 pozwala na nieograniczone próby, co może zużyć znaczną ilość API" }, + "toolRepetitionSoftLimit": { + "label": "Próg miękkiego ostrzeżenia o powtórzeniach", + "description": "Po tylu identycznych kolejnych wywołaniach narzędzia narzędzie zostaje zablokowane, a AI jest proszona o uzasadnienie ponownej próby — bez angażowania Ciebie. Ustaw 0, aby wyłączyć miękkie ostrzeżenia." + }, "reasoningEffort": { "label": "Wysiłek rozumowania modelu", "none": "Brak", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 7c3165c3de..1cd6b94a8f 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Tentativas ilimitadas ativadas (prosseguimento automático). O diálogo nunca aparecerá.", "warning": "⚠️ Definir como 0 permite tentativas ilimitadas, o que pode consumir um uso significativo da API" }, + "toolRepetitionSoftLimit": { + "label": "Limite de aviso suave de repetição", + "description": "Após esse número de chamadas de ferramenta idênticas consecutivas, a ferramenta é bloqueada e a IA é solicitada a justificar a nova tentativa, sem envolver você. Defina como 0 para desativar os avisos suaves." + }, "reasoningEffort": { "label": "Esforço de raciocínio do modelo", "none": "Nenhum", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 6fb07bd8bf..dd4c4af304 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Включены неограниченные повторные попытки (автоматическое продолжение). Диалоговое окно никогда не появится.", "warning": "⚠️ Установка значения 0 разрешает неограниченные повторные попытки, что может значительно увеличить использование API" }, + "toolRepetitionSoftLimit": { + "label": "Порог мягкого предупреждения о повторах", + "description": "После такого количества идентичных последовательных вызовов инструмента инструмент блокируется, и ИИ предлагается обосновать повторную попытку — без вашего участия. Установите 0, чтобы отключить мягкие предупреждения." + }, "reasoningEffort": { "label": "Усилия по рассуждению модели", "none": "Нет", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 346ee6b568..14f40b2190 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Sınırsız yeniden deneme etkin (otomatik devam et). Diyalog asla görünmeyecek.", "warning": "⚠️ 0'a ayarlamak, önemli API kullanımına neden olabilecek sınırsız yeniden denemeye izin verir" }, + "toolRepetitionSoftLimit": { + "label": "Tekrar Yumuşak Uyarı Eşiği", + "description": "Bu kadar aynı ardışık araç çağrısından sonra araç engellenir ve sizi dahil etmeden yapay zekadan yeniden denemeyi gerekçelendirmesi istenir. Yumuşak uyarıları devre dışı bırakmak için 0 olarak ayarlayın." + }, "reasoningEffort": { "label": "Model Akıl Yürütme Çabası", "none": "Yok", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4e42c9bc03..4918a54b32 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "Đã bật thử lại không giới hạn (tự động tiếp tục). Hộp thoại sẽ không bao giờ xuất hiện.", "warning": "⚠️ Đặt thành 0 cho phép thử lại không giới hạn, điều này có thể tiêu tốn mức sử dụng API đáng kể" }, + "toolRepetitionSoftLimit": { + "label": "Ngưỡng cảnh báo mềm khi lặp lại", + "description": "Sau số lần gọi công cụ giống hệt liên tiếp này, công cụ sẽ bị chặn và AI được yêu cầu giải thích lý do thử lại — mà không cần bạn tham gia. Đặt thành 0 để tắt cảnh báo mềm." + }, "reasoningEffort": { "label": "Nỗ lực suy luận của mô hình", "none": "Không", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 90b683b86b..d9238e21f2 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -603,6 +603,10 @@ "unlimitedDescription": "已启用无限重试(自动继续)。对话框将永远不会出现。", "warning": "⚠️ 设置为 0 允许无限重试,这可能会消耗大量 API 使用量" }, + "toolRepetitionSoftLimit": { + "label": "重复软警告阈值", + "description": "在连续这么多次相同的工具调用后,该工具将被阻止,并要求 AI 说明重试理由——无需你参与。设置为 0 可禁用软警告。" + }, "reasoningEffort": { "label": "模型推理强度", "none": "无", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 3f0defb886..60bde80f02 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -630,6 +630,10 @@ "unlimitedDescription": "已啟用無限重試(自動繼續)。對話方塊將永遠不會出現。", "warning": "⚠️ 設定為 0 允許無限重試,這可能會消耗大量 API 使用量" }, + "toolRepetitionSoftLimit": { + "label": "重複軟警告閾值", + "description": "在連續這麼多次相同的工具呼叫後,該工具將被封鎖,並要求 AI 說明重試理由——無需你參與。設定為 0 可停用軟警告。" + }, "reasoningEffort": { "label": "模型推理強度", "none": "無",