diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 0b898f1b66..d3de344ba6 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -54,6 +54,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 * diff --git a/packages/types/src/provider-settings/common.ts b/packages/types/src/provider-settings/common.ts index e73a05f143..7e4c49bab3 100644 --- a/packages/types/src/provider-settings/common.ts +++ b/packages/types/src/provider-settings/common.ts @@ -7,12 +7,29 @@ export const API_PROVIDER_FIELD = "apiProvider" export const SETTINGS_SHAPE_FIELD = "settingsShape" export const API_MODEL_ID_FIELD = "apiModelId" +// Treat a persisted `null` as an unset value. Previously persisted or imported +// profiles may store `null` for these fields; converting `null` to `undefined` +// before validation keeps the inferred type as `NonNull | undefined` (so +// consumers don't need to handle `null`) while ensuring the whole profile is not +// dropped during per-profile `safeParse` in ProviderSettingsManager.load(). +// +// The argument is a nullish schema (accepts `null` and `undefined`). At runtime we +// map `null` to `undefined` before validation; the return type strips `null` from +// the output because it can never survive the preprocessing step. +const nullableOptional = (schema: Schema) => + z.preprocess((value) => (value === null ? undefined : value), schema) as unknown as z.ZodType< + Exclude, null>, + z.ZodTypeDef, + z.input + > + export const baseProviderSettingsShape = { includeMaxTokens: z.boolean().optional(), todoListEnabled: z.boolean().optional(), modelTemperature: z.number().nullish(), rateLimitSeconds: z.number().optional(), - consecutiveMistakeLimit: z.number().min(0).optional(), + consecutiveMistakeLimit: nullableOptional(z.number().min(0).nullish()), + toolRepetitionSoftLimit: nullableOptional(z.number().min(0).nullish()), enableReasoningEffort: z.boolean().optional(), reasoningEffort: reasoningEffortSettingSchema.optional(), modelMaxTokens: z.number().optional(), diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index cac34cc6f2..f36c69ad0d 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 aa278e077d..2915bb1ae7 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -74,7 +74,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 ed90759cdc..4bfe10e142 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -48,7 +48,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..7c4553ec2e --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts @@ -0,0 +1,313 @@ +// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts + +import { providerIdentifiers } from "@roo-code/types" + +import type { ToolUse } from "../../../shared/tools" +import { ToolRepetitionDetector } from "../../tools/ToolRepetitionDetector" +import { presentAssistantMessage } from "../presentAssistantMessage" + +type ToolBlock = { + type: string + id?: string + name?: string + params?: Record + nativeArgs?: Record + partial?: boolean +} + +type ToolResultBlock = { + type: string + tool_use_id?: string + content?: string + is_error?: boolean + text?: string +} + +type MockTask = Record + +// Mock dependencies +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn(() => true), +})) + +// Translations are not loaded under test (see src/i18n/setup.ts), so the real +// `t` returns only the bare key and drops interpolation options. Return the +// key plus serialized options instead, so tests can assert both which message +// was selected and which tool name was interpolated, without depending on the +// English wording. +vi.mock("../../../i18n", () => ({ + t: (key: string, options?: Record) => (options ? `${key} ${JSON.stringify(options)}` : key), +})) + +// 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: unknown, block: ToolBlock, { pushToolResult }: { pushToolResult: (result: string) => void }) => { + 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(), + }, + }, +})) + +// Small explicit limits keep the priming in each test short: +// - 1st identical call: allowed +// - 2nd identical call: soft blocked +// - 3rd identical call: hard blocked +const SOFT_LIMIT = 1 +const HARD_LIMIT = 2 + +function makeReadFileBlock(id: string, path = "test.txt"): ToolUse<"read_file"> { + return { + type: "tool_use", + id, + name: "read_file", + params: { path }, + nativeArgs: { path }, + partial: false, + } +} + +/** + * Feeds `times` identical calls into the detector, simulating the model having + * already made the same tool call that many times in earlier turns. + */ +function primeDetector(detector: ToolRepetitionDetector, block: ToolUse, times: number) { + for (let i = 0; i < times; i++) { + detector.check({ ...block }) + } +} + +describe("presentAssistantMessage - Tool Repetition Detection", () => { + let mockTask: MockTask + let detector: ToolRepetitionDetector + + beforeEach(() => { + vi.clearAllMocks() + + detector = new ToolRepetitionDetector(SOFT_LIMIT, HARD_LIMIT) + + 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: HARD_LIMIT, + clineMessages: [], + getTaskMode: vi.fn().mockResolvedValue("code"), + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + api: { + getModel: () => ({ id: "test-model", info: {} }), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: detector, + 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: ToolResultBlock) => { + const userMessageContent = mockTask.userMessageContent as ToolResultBlock[] + const existingResult = userMessageContent.find( + (block) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + ;(mockTask.userMessageContent as ToolResultBlock[]).push(toolResult) + return true + }) + }) + + function findToolResult(toolCallId: string) { + return (mockTask.userMessageContent as ToolResultBlock[]).find( + (item) => item.type === "tool_result" && item.tool_use_id === toolCallId, + ) + } + + async function present() { + await presentAssistantMessage(mockTask as unknown as Parameters[0]) + } + + it("should execute the tool normally on the first call", async () => { + const toolCallId = "tool_call_allow" + const block = makeReadFileBlock(toolCallId) + mockTask.assistantMessageContent = [block] + const checkSpy = vi.spyOn(detector, "check") + + await present() + + // The real detector should have been consulted with this block. + expect(checkSpy).toHaveBeenCalledTimes(1) + expect(checkSpy).toHaveBeenCalledWith(expect.objectContaining({ name: "read_file", id: toolCallId })) + expect(checkSpy).toHaveReturnedWith({ action: "allow" }) + + // 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 = findToolResult(toolCallId) + expect(toolResult).toBeDefined() + expect(toolResult?.content).toContain("read_file") + expect(toolResult?.content).not.toContain("tools:toolRepetition") + expect(toolResult?.is_error).toBeUndefined() + }) + + it("should execute the tool when the previous call had different arguments", async () => { + primeDetector(detector, makeReadFileBlock("earlier", "other.txt"), SOFT_LIMIT + 1) + + const toolCallId = "tool_call_different_args" + mockTask.assistantMessageContent = [makeReadFileBlock(toolCallId, "test.txt")] + + await present() + + expect(readFileHandle).toHaveBeenCalledTimes(1) + expect(mockTask.ask).not.toHaveBeenCalled() + expect(findToolResult(toolCallId)?.content).toContain("test.txt") + }) + + it("should soft block without involving the user and return an error to the model", async () => { + const toolCallId = "tool_call_soft_block" + const block = makeReadFileBlock(toolCallId) + primeDetector(detector, block, SOFT_LIMIT) + mockTask.assistantMessageContent = [block] + + await present() + + // The user should NOT have been asked anything for a soft block. + expect(mockTask.ask).not.toHaveBeenCalled() + + // The tool must not be executed when it is soft blocked. + expect(readFileHandle).not.toHaveBeenCalled() + + // A tool_result with the soft block message should have been pushed. + const toolResult = findToolResult(toolCallId) + // The content is the JSON produced by formatResponse.toolError(); the + // detector's message is carried in its `error` field. + expect(toolResult).toBeDefined() + const { error } = JSON.parse(String(toolResult?.content)) as { error: string } + expect(error).toBe(`tools:toolRepetitionSoftBlock ${JSON.stringify({ toolName: "read_file" })}`) + + // 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" + const block = makeReadFileBlock(toolCallId) + primeDetector(detector, block, HARD_LIMIT) + mockTask.assistantMessageContent = [block] + + mockTask.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + + await present() + + // The user must be asked for guidance with the resolved message key + // and a detail message naming the repeated tool. + expect(mockTask.ask).toHaveBeenCalledTimes(1) + expect(mockTask.ask).toHaveBeenCalledWith( + "mistake_limit_reached", + expect.stringContaining("tools:toolRepetitionLimitReached"), + ) + expect(mockTask.ask).toHaveBeenCalledWith( + "mistake_limit_reached", + expect.stringContaining('"toolName":"read_file"'), + ) + + // The tool must not be executed when it is hard blocked. + expect(readFileHandle).not.toHaveBeenCalled() + + // 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 = findToolResult(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" + const block = makeReadFileBlock(toolCallId) + primeDetector(detector, block, HARD_LIMIT) + mockTask.assistantMessageContent = [block] + + mockTask.ask = vi.fn().mockResolvedValue({ + response: "messageResponse", + text: "try a different file", + images: [], + }) + + await present() + + // The tool must not be executed when it is hard blocked. + expect(readFileHandle).not.toHaveBeenCalled() + + // 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 as ToolResultBlock[]).find( + (item) => item.type === "text" && String(item.text).includes("try a different file"), + ) + expect(feedbackBlock).toBeDefined() + expect(feedbackBlock?.text).toContain("Tool repetition limit reached") + }) +}) 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 8cfaa10972..c95ee706c9 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -51,7 +51,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 b11538a2c6..6e150c7f61 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -784,8 +784,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 3fcc0e6e43..e99592df3c 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,15 @@ export class ProviderSettingsManager { isDirty = true } + if (!providerProfiles.migrations.toolRepetitionLimitsMigrated) { + // Partial defaults are safe to persist; the flag stays unset so the migration retries. + const migrated = await this.migrateToolRepetitionLimits(providerProfiles) + if (migrated) { + providerProfiles.migrations.toolRepetitionLimitsMigrated = true + } + isDirty = true + } + if (!providerProfiles.migrations.todoListEnabledMigrated) { await this.migrateTodoListEnabled(providerProfiles) providerProfiles.migrations.todoListEnabledMigrated = true @@ -271,6 +283,30 @@ export class ProviderSettingsManager { } } + /** + * Defaults `toolRepetitionSoftLimit` on every profile that lacks it. + * Profiles that explicitly disabled the hard stop (`consecutiveMistakeLimit === 0`) + * get the soft tier disabled too, so existing "unlimited" profiles keep + * their previous behavior instead of silently soft-blocking repeats forever. + * Returns true only if every profile was processed successfully, so the + * caller can avoid marking the migration complete after a failure. + */ + private async migrateToolRepetitionLimits(providerProfiles: ProviderProfiles): Promise { + try { + for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { + // Default the soft warning threshold. + if (apiConfig.toolRepetitionSoftLimit == null) { + apiConfig.toolRepetitionSoftLimit = + apiConfig.consecutiveMistakeLimit === 0 ? 0 : DEFAULT_TOOL_REPETITION_SOFT_LIMIT + } + } + return true + } catch (error) { + console.error(`[MigrateToolRepetitionLimits] Failed to migrate tool repetition limits:`, error) + return false + } + } + 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 13e1aeeb2d..1a54342df2 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -35,7 +35,7 @@ vi.mock("../../../api", async () => { } } return { - buildApiHandler: (config: any) => ({ + buildApiHandler: (config: { apiProvider?: string; apiModelId?: string }) => ({ getModel: () => ({ id: config?.apiModelId ?? "", info: modelInfoFor(config) }), }), } @@ -99,6 +99,7 @@ describe("ProviderSettingsManager", () => { rateLimitSecondsMigrated: true, openAiHeadersMigrated: true, consecutiveMistakeLimitMigrated: true, + toolRepetitionLimitsMigrated: true, todoListEnabledMigrated: true, claudeCodeLegacySettingsMigrated: true, routerProviderMigrated: true, @@ -219,6 +220,217 @@ 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: providerIdentifiers.anthropic, + consecutiveMistakeLimit: 7, + }, + preset: { + apiProvider: providerIdentifiers.anthropic, + // Pre-existing repetition limits should not be overwritten + toolRepetitionSoftLimit: 1, + }, + unlimited: { + apiProvider: providerIdentifiers.anthropic, + // Hard stop disabled: the soft tier must be disabled too + consecutiveMistakeLimit: 0, + }, + unlimitedPreset: { + apiProvider: providerIdentifiers.anthropic, + consecutiveMistakeLimit: 0, + // An explicit soft limit is preserved even when the hard stop is disabled + toolRepetitionSoftLimit: 4, + }, + }, + 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) + + // Profiles with the hard stop disabled keep the soft tier disabled + expect(storedConfig.apiConfigs.unlimited.toolRepetitionSoftLimit).toEqual(0) + expect(storedConfig.apiConfigs.unlimited.consecutiveMistakeLimit).toEqual(0) + expect(storedConfig.apiConfigs.unlimitedPreset.toolRepetitionSoftLimit).toEqual(4) + + expect(storedConfig.migrations.toolRepetitionLimitsMigrated).toEqual(true) + }) + + it("should preserve profiles with persisted null repetition limits and let migrations default them", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: { + apiProvider: providerIdentifiers.anthropic, + id: "default", + // A previously persisted null value must not cause the profile to be dropped + // during load() before migrateToolRepetitionLimits can default it. + consecutiveMistakeLimit: null, + toolRepetitionSoftLimit: null, + }, + }, + migrations: { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: false, + toolRepetitionLimitsMigrated: false, + }, + }), + ) + + await providerSettingsManager.initialize() + + const calls = mockSecrets.store.mock.calls + const storedConfig = JSON.parse(calls[calls.length - 1][1]) + + // The profile must survive load() rather than being silently deleted. + expect(storedConfig.apiConfigs.default).toBeDefined() + // null values are normalized to defaults by the migrations. + expect(storedConfig.apiConfigs.default.consecutiveMistakeLimit).toEqual(3) + expect(storedConfig.apiConfigs.default.toolRepetitionSoftLimit).toEqual(2) + }) + + it("should not mark toolRepetitionLimitsMigrated complete when the migration fails", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: { + apiProvider: providerIdentifiers.anthropic, + id: "default", + }, + }, + migrations: { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + toolRepetitionLimitsMigrated: false, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + routerProviderMigrated: true, + }, + }), + ) + + // Typed view of the private method so it can be spied on without `as any`. + const migrationSpy = vi + .spyOn( + providerSettingsManager as unknown as { + migrateToolRepetitionLimits: (profiles: ProviderProfiles) => Promise + }, + "migrateToolRepetitionLimits", + ) + .mockResolvedValue(false) + + await providerSettingsManager.initialize() + + expect(migrationSpy).toHaveBeenCalledTimes(1) + const calls = mockSecrets.store.mock.calls + const storedConfig = JSON.parse(calls[calls.length - 1][1]) + // The flag must remain false so the migration is retried on next initialization. + expect(storedConfig.migrations.toolRepetitionLimitsMigrated).toBe(false) + }) + + it("should retry the tool repetition migration on the next initialize after a failure", async () => { + const persisted = { + currentApiConfigName: "default", + apiConfigs: { + default: { + apiProvider: providerIdentifiers.anthropic, + id: "default", + }, + }, + migrations: { + rateLimitSecondsMigrated: true, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + toolRepetitionLimitsMigrated: false, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + routerProviderMigrated: true, + }, + } + let stored = JSON.stringify(persisted) + mockSecrets.get.mockImplementation(async () => stored) + mockSecrets.store.mockImplementation(async (_key: string, value: string) => { + stored = value + }) + + const migrationSpy = vi + .spyOn( + providerSettingsManager as unknown as { + migrateToolRepetitionLimits: (profiles: ProviderProfiles) => Promise + }, + "migrateToolRepetitionLimits", + ) + .mockResolvedValueOnce(false) + + await providerSettingsManager.initialize() + expect(JSON.parse(stored).migrations.toolRepetitionLimitsMigrated).toBe(false) + + // Second run uses the real implementation and succeeds. + await providerSettingsManager.initialize() + expect(migrationSpy).toHaveBeenCalledTimes(2) + const finalConfig = JSON.parse(stored) + expect(finalConfig.migrations.toolRepetitionLimitsMigrated).toBe(true) + expect(finalConfig.apiConfigs.default.toolRepetitionSoftLimit).toEqual(2) + }) + + it("should not throw and report failure 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: providerIdentifiers.anthropic }) + const providerProfiles = { + currentApiConfigName: "default", + apiConfigs: { + frozen: frozenConfig, + }, + } as unknown as ProviderProfiles + + // The migration must swallow the error and report failure so the caller + // does not mark the migration as complete. + await expect( + ( + providerSettingsManager as unknown as { + migrateToolRepetitionLimits: (profiles: ProviderProfiles) => Promise + } + ).migrateToolRepetitionLimits(providerProfiles), + ).resolves.toBe(false) + + 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({ @@ -334,7 +546,7 @@ describe("ProviderSettingsManager", () => { apiProvider: retiredProviderIdentifiers.roo, apiModelId: "roo/code-supernova", rooApiKey: "router-key", - } as any) + } as unknown as ProviderSettings) const calls = mockSecrets.store.mock.calls const storedConfig = JSON.parse(calls[calls.length - 1][1]) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7f92f3647f..d9c0b42a80 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -50,6 +50,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, @@ -452,6 +453,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 @@ -607,6 +609,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, @@ -678,6 +681,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) @@ -733,7 +737,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) { @@ -2366,6 +2373,17 @@ export class Task extends EventEmitter implements TaskLike { // Update the configuration and rebuild the API handler this.apiConfiguration = newApiConfiguration this.api = buildApiHandler(this.apiConfiguration) + + // Refresh only the soft tool-repetition limit from the new profile so an + // active task immediately enforces it. The task-level + // `consecutiveMistakeLimit` (the hard stop) is intentionally preserved: + // it may have been set explicitly at task creation (e.g. the extension + // API creates tasks with `Number.MAX_SAFE_INTEGER`), and a profile switch + // must not silently override it. The detector's in-progress counting + // state is preserved. + this.toolRepetitionSoftLimit = + newApiConfiguration.toolRepetitionSoftLimit ?? DEFAULT_TOOL_REPETITION_SOFT_LIMIT + this.toolRepetitionDetector.updateLimits(this.toolRepetitionSoftLimit, this.consecutiveMistakeLimit) } public async submitUserMessage( diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 88db6f2f2a..d8027cd305 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1077,6 +1077,76 @@ 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, + consecutiveMistakeLimit: 7, + toolRepetitionSoftLimit: 3, + task: "test task", + startTask: false, + }) + + expect(cline.toolRepetitionSoftLimit).toBe(3) + + // Verify the constructor actually wires the soft limit into the + // detector (soft=3 differs from the default of 2). + const identicalToolCall = { + type: "tool_use" as const, + name: "execute_command" as const, + params: { command: "ls" }, + partial: false, + } + const actions = Array.from({ length: 4 }, () => cline.toolRepetitionDetector.check(identicalToolCall).action) + expect(actions).toEqual(["allow", "allow", "allow", "soft_block"]) + }) + + 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() + + // The detector's counter increments from the second identical call + // onward (the first call establishes the baseline at count 0), so the + // count reaches the hard limit of 7 on the 8th identical call. + const identicalToolCall = { + type: "tool_use" as const, + name: "execute_command" as const, + params: { command: "ls" }, + partial: false, + } + + // Calls 1-7: count stays below the hard limit, so the hard stop must not fire. + for (let call = 1; call <= 7; call++) { + const result = cline.toolRepetitionDetector.check(identicalToolCall) + expect(result.action).not.toBe("hard_block") + } + + // Call 8: count reaches the hard limit of 7, so the hard stop fires. + const hardBlockingCall = cline.toolRepetitionDetector.check(identicalToolCall) + expect(hardBlockingCall.action).toBe("hard_block") + }) + it("should require either task or historyItem", () => { expect(() => { new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) @@ -1084,6 +1154,80 @@ describe("Cline", () => { }) }) + describe("updateApiConfiguration", () => { + it("refreshes the soft limit but preserves the task-level hard limit when the provider profile changes", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: { ...mockApiConfig, consecutiveMistakeLimit: 5, toolRepetitionSoftLimit: 2 }, + consecutiveMistakeLimit: 5, + toolRepetitionSoftLimit: 2, + task: "test task", + startTask: false, + }) + + expect(cline.consecutiveMistakeLimit).toBe(5) + expect(cline.toolRepetitionSoftLimit).toBe(2) + + // Switching provider profile mid-task applies the new soft limit to + // the live detector, but keeps the hard limit captured at creation. + cline.updateApiConfiguration({ + ...mockApiConfig, + consecutiveMistakeLimit: 2, + toolRepetitionSoftLimit: 1, + }) + + expect(cline.consecutiveMistakeLimit).toBe(5) + expect(cline.toolRepetitionSoftLimit).toBe(1) + + const identicalToolCall = { + type: "tool_use" as const, + name: "execute_command" as const, + params: { command: "ls" }, + partial: false, + } + + // Call 1 (count = 0) -> allow + expect(cline.toolRepetitionDetector.check(identicalToolCall).action).toBe("allow") + // Calls 2-5 (count = 1..4) -> soft_block (new soft limit 1, hard limit still 5) + for (let call = 2; call <= 5; call++) { + expect(cline.toolRepetitionDetector.check(identicalToolCall).action).toBe("soft_block") + } + // Call 6 (count = 5) -> hard_block (preserved hard limit 5) + expect(cline.toolRepetitionDetector.check(identicalToolCall).action).toBe("hard_block") + }) + + it("does not override an explicit task-level consecutiveMistakeLimit (e.g. extension API tasks)", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: { ...mockApiConfig, consecutiveMistakeLimit: 3 }, + consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, + task: "test task", + startTask: false, + }) + + cline.updateApiConfiguration({ ...mockApiConfig, consecutiveMistakeLimit: 3 }) + + expect(cline.consecutiveMistakeLimit).toBe(Number.MAX_SAFE_INTEGER) + }) + + it("falls back to the default soft limit when the new profile omits it", () => { + const cline = new Task({ + provider: mockProvider, + apiConfiguration: { ...mockApiConfig, consecutiveMistakeLimit: 7, toolRepetitionSoftLimit: 4 }, + consecutiveMistakeLimit: 7, + toolRepetitionSoftLimit: 4, + task: "test task", + startTask: false, + }) + + cline.updateApiConfiguration({ ...mockApiConfig }) + + // The default soft limit is 2; the task-level hard limit is preserved. + expect(cline.consecutiveMistakeLimit).toBe(7) + expect(cline.toolRepetitionSoftLimit).toBe(2) + }) + }) + describe("task-local configuration isolation", () => { it("uses the task mode and API configuration when focused provider state differs", async () => { const taskApiConfiguration: ProviderSettings = { diff --git a/src/core/tools/ToolRepetitionDetector.ts b/src/core/tools/ToolRepetitionDetector.ts index 27592c5210..716f5416af 100644 --- a/src/core/tools/ToolRepetitionDetector.ts +++ b/src/core/tools/ToolRepetitionDetector.ts @@ -2,37 +2,70 @@ 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 softWarningLimit: number + private 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) { + this.hardStopLimit = Math.max(0, hardLimit) + this.softWarningLimit = this.normalizeSoftLimit(softLimit) } /** * 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 +77,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 +92,72 @@ 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" } + } + + /** + * Updates the soft and hard limits in place while preserving the current + * repetition tracking state. + * + * This is used when the active provider profile changes during a task, so + * the detector immediately enforces the newly configured limits without + * discarding an in-progress repetition streak. + * + * @param softLimit The new soft warning limit. Negative values are treated + * as 0 (disabled). + * @param hardLimit The new hard stop limit. Negative values are treated as 0 + * (disabled). + */ + public updateLimits(softLimit: number, hardLimit: number): void { + this.hardStopLimit = Math.max(0, hardLimit) + this.softWarningLimit = this.normalizeSoftLimit(softLimit) + } + + /** + * Normalizes the soft warning limit so that, when the hard stop limit is + * enabled, the soft warning always triggers before the hard stop. + * + * The hard stop check runs before the soft warning check, so a soft limit + * greater than or equal to the hard limit would never take effect. Imported + * or migrated provider profiles may configure such a combination, so we clamp + * the soft limit to at most one below the hard limit here. + * + * `this.hardStopLimit` must be set before this is called. + * + * @param softLimit The requested soft warning limit. Negative values are + * treated as 0 (disabled). + * @returns The normalized soft warning limit. + */ + private normalizeSoftLimit(softLimit: number): number { + // Treat negative values as 0 (unlimited / disabled). + const soft = Math.max(0, softLimit) + + // When the hard stop is disabled (0), leave the soft limit untouched. + if (this.hardStopLimit <= 0) { + return soft + } + + // Ensure the soft warning fires before the hard stop. + return Math.max(0, Math.min(soft, this.hardStopLimit - 1)) + } + + /** + * 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..3eb10e1130 100644 --- a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts +++ b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts @@ -12,6 +12,9 @@ vitest.mock("../../../i18n", () => ({ 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 +31,65 @@ 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) + 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") + }) - // First call (counter = 0) - const result1 = detector.check(createToolUse("test", "test-tool")) - expect(result1.allowExecution).toBe(true) + it("should clamp the soft limit below the hard limit so the soft warning fires first", () => { + // A soft limit equal to the hard limit (as imported/migrated profiles + // may configure) would otherwise never soft block because the hard + // stop check runs first. + const detector = new ToolRepetitionDetector(3, 3) + 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 (soft limit clamped to hard - 1 = 2) + 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") + }) - // Second identical call (counter = 1) - const result2 = detector.check(createToolUse("test", "test-tool")) - expect(result2.allowExecution).toBe(true) + it("should clamp a soft limit greater than the hard limit", () => { + const detector = new ToolRepetitionDetector(10, 2) + const tool = createToolUse("test", "test-tool") - // Third identical call (counter = 2) reaches the custom limit - const result3 = detector.check(createToolUse("test", "test-tool")) - expect(result3.allowExecution).toBe(false) + // Call 1 (count = 0) -> allow + expect(detector.check(tool).action).toBe("allow") + // Call 2 (count = 1) -> soft_block (soft limit clamped to hard - 1 = 1) + expect(detector.check(tool).action).toBe("soft_block") + // Call 3 (count = 2) -> hard_block (reaches hard limit 2) + expect(detector.check(tool).action).toBe("hard_block") }) }) @@ -72,341 +98,213 @@ 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) + const detector = new ToolRepetitionDetector(1, 2) - // First call - detector.check(createToolUse("same", "same-tool")) + // First call to "same" (count = 0) -> allow + expect(detector.check(createToolUse("same", "same-tool")).action).toBe("allow") - // Second identical call would reach limit of 2, but we'll make a different call - detector.check(createToolUse("different", "different-tool")) + // Different tool resets the counter (count = 0) -> allow + expect(detector.check(createToolUse("different", "different-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) + // Back to first tool - counter was reset (count = 0) -> allow + expect(detector.check(createToolUse("same", "same-tool")).action).toBe("allow") }) }) - // ===== 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) + // ===== 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") - // Second identical call (counter = 1) - const result2 = detector.check(createToolUse("repeat", "repeat-tool")) - expect(result2.allowExecution).toBe(true) + expect(detector.check(tool).action).toBe("allow") + expect(detector.check(tool).action).toBe("allow") - // Third identical call (counter = 2) - const result3 = detector.check(createToolUse("repeat", "repeat-tool")) - expect(result3.allowExecution).toBe(true) - - // Fourth identical call (counter = 3) reaches limit - const result4 = detector.check(createToolUse("repeat", "repeat-tool")) - expect(result4.allowExecution).toBe(false) + const result = detector.check(tool) + expect(result.action).toBe("soft_block") + if (result.action === "soft_block") { + expect(result.message).toContain("repeat-tool") + } }) - }) - - // ===== Repetition Reaches Limit tests ===== - describe("repetition reaches limit", () => { - it("should block execution when repetition reaches the limit", () => { - const detector = new ToolRepetitionDetector(3) - - // First call (counter = 0) - detector.check(createToolUse("repeat", "repeat-tool")) - // Second identical call (counter = 1) - detector.check(createToolUse("repeat", "repeat-tool")) + 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") - // 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") // 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 reset internal state after limit is reached", () => { - const detector = new ToolRepetitionDetector(2) + 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") - // 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) + 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 }) }) - // ===== 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) - - // The same tool that previously caused problems should now be allowed - const result = detector.check(createToolUse("problem", "problem-tool")) - expect(result.allowExecution).toBe(true) + // ===== 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") + } }) - 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 reset internal state after a hard block", () => { + const detector = new ToolRepetitionDetector(2, 2) + const tool = createToolUse("repeat", "repeat-tool") - // First call after reset - detector.check(createToolUse("repeat", "repeat-tool")) + detector.check(tool) // count 0 + // With soft=2 hard=2, the soft limit is clamped to hard - 1 = 1, so the + // second identical call soft blocks before the hard stop. + const limitResult = detector.check(tool) // count 1 -> soft + expect(limitResult.action).toBe("soft_block") + const hard = detector.check(tool) // count 2 -> hard + expect(hard.action).toBe("hard_block") - // 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() + // After hard block, state resets - a new identical call is allowed again + expect(detector.check(tool).action).toBe("allow") }) - }) - // ===== 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" + 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") - // 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) + 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++) { + expect(detector.check(tool).action).toBe("soft_block") + } }) }) - // ===== 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() - }) + // ===== 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") - 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) + for (let i = 0; i < 20; i++) { + expect(detector.check(tool).action).toBe("allow") } - - // 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) + it("should treat negative limits as 0 (unlimited)", () => { + const detector = new ToolRepetitionDetector(-1, -5) + const tool = createToolUse("tool", "tool-name") - // Since parameters are sorted alphabetically in the serialized JSON, - // these should be considered identical - expect(result.allowExecution).toBe(false) - expect(result.askUser).toBeDefined() + for (let i = 0; i < 10; i++) { + expect(detector.check(tool).action).toBe("allow") + } }) }) - // ===== 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() + // ===== updateLimits (provider profile change) ===== + describe("updateLimits", () => { + it("should enforce the new limits after they are updated", () => { + const detector = new ToolRepetitionDetector(2, 5) + const tool = createToolUse("repeat", "repeat-tool") + + // Tighten the limits, as would happen when switching to a stricter + // provider profile during an active task. + detector.updateLimits(1, 2) + + // Call 1 (count = 0) -> allow + expect(detector.check(tool).action).toBe("allow") + // Call 2 (count = 1) -> soft_block (reaches new soft limit 1) + expect(detector.check(tool).action).toBe("soft_block") + // Call 3 (count = 2) -> hard_block (reaches new hard limit 2) + expect(detector.check(tool).action).toBe("hard_block") }) - it("should allow first 2 calls but block on the 3rd call for limit 2", () => { - const detector = new ToolRepetitionDetector(2) + it("should preserve the in-progress repetition count when limits change", () => { + const detector = new ToolRepetitionDetector(5, 10) + const tool = createToolUse("repeat", "repeat-tool") - // First call (counter = 0) - const result1 = detector.check(createToolUse("tool", "tool-name")) - expect(result1.allowExecution).toBe(true) + // Build up a streak under the original loose limits. + expect(detector.check(tool).action).toBe("allow") // count 0 + expect(detector.check(tool).action).toBe("allow") // count 1 - // Second identical call (counter = 1) - const result2 = detector.check(createToolUse("tool", "tool-name")) - expect(result2.allowExecution).toBe(true) + // Tighten the soft limit to 2. The preserved count (currently 1) + // means the very next identical call reaches the new soft limit. + detector.updateLimits(2, 5) - // 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() + // Call (count = 2) -> soft_block under the new soft limit. + expect(detector.check(tool).action).toBe("soft_block") }) - 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) - - // Third identical call (counter = 2) - const result3 = detector.check(createToolUse("tool", "tool-name")) - expect(result3.allowExecution).toBe(true) - - // 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() + it("should clamp the soft limit below the hard limit after updating", () => { + const detector = new ToolRepetitionDetector(2, 5) + const tool = createToolUse("repeat", "repeat-tool") + + // Update to an equal soft/hard combination, as an imported profile may + // configure. The soft limit should be clamped to hard - 1 = 2. + detector.updateLimits(3, 3) + + // 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 (clamped soft limit 2) + 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") }) - it("should never block when limit is 0 (unlimited)", () => { - const detector = new ToolRepetitionDetector(0) + it("should treat negative updated limits as 0 (disabled)", () => { + const detector = new ToolRepetitionDetector(2, 5) + const tool = createToolUse("repeat", "repeat-tool") - // Try many identical calls - for (let i = 0; i < 10; i++) { - const result = detector.check(createToolUse("tool", "tool-name")) - expect(result.allowExecution).toBe(true) - expect(result.askUser).toBeUndefined() - } - }) - - it("should handle different limits correctly", () => { - // Test with limit of 5 - const detector5 = new ToolRepetitionDetector(5) - const tool = createToolUse("tool", "tool-name") + detector.updateLimits(-1, -1) - // 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 < 10; 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) - 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) - }) + // ===== Edge Cases ===== + describe("edge cases", () => { + it("should treat tools with same parameters in different order as identical", () => { + const detector = new ToolRepetitionDetector(2, 5) - it("should handle negative limits as 0 (unlimited)", () => { - const detector = new ToolRepetitionDetector(-1) + 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" })) - // 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() - } + // 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 = { @@ -429,18 +327,13 @@ describe("ToolRepetitionDetector", () => { }, } - // 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 = { @@ -453,20 +346,13 @@ describe("ToolRepetitionDetector", () => { }, } - // 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", @@ -492,13 +378,12 @@ describe("ToolRepetitionDetector", () => { }, } - // 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", @@ -523,22 +408,19 @@ describe("ToolRepetitionDetector", () => { } // 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 874e2f8f08..e00d5300fa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1347,6 +1347,7 @@ export class ClineProvider enableCheckpoints, checkpointTimeout, consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, + toolRepetitionSoftLimit: apiConfiguration.toolRepetitionSoftLimit, historyItem, experiments, rootTask: historyItem.rootTask, @@ -3417,6 +3418,7 @@ export class ClineProvider enableCheckpoints, checkpointTimeout, consecutiveMistakeLimit: effectiveApiConfiguration.consecutiveMistakeLimit, + toolRepetitionSoftLimit: effectiveApiConfiguration.toolRepetitionSoftLimit, task: text, images, experiments, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 8130c33191..ab07159ca0 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -654,11 +654,6 @@ "count": 1 } }, - "core/config/__tests__/ProviderSettingsManager.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "core/context-management/__tests__/context-management.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -919,11 +914,6 @@ "count": 16 } }, - "core/tools/__tests__/ToolRepetitionDetector.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "core/tools/__tests__/askFollowupQuestionTool.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 20 diff --git a/src/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index d76adf23bc..6e716c4955 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...", "missingToolParameter": "Zoo ha intentat utilitzar {{toolName}} sense valor per al paràmetre obligatori '{{paramName}}'. Reintentant...", "missingToolParameterWithPath": "Zoo ha intentat utilitzar {{toolName}} per a '{{relPath}}' sense valor per al paràmetre obligatori '{{paramName}}'. Reintentant...", diff --git a/src/i18n/locales/de/tools.json b/src/i18n/locales/de/tools.json index c3f17707ea..695f0b444e 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...", "missingToolParameter": "Zoo hat versucht, {{toolName}} ohne Wert für den erforderlichen Parameter '{{paramName}}' zu verwenden. Wiederhole Versuch...", "missingToolParameterWithPath": "Zoo hat versucht, {{toolName}} für '{{relPath}}' ohne Wert für den erforderlichen Parameter '{{paramName}}' zu verwenden. Wiederhole Versuch...", diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index c29c717a87..306e71ac86 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...", "missingToolParameter": "Zoo tried to use {{toolName}} without value for required parameter '{{paramName}}'. Retrying...", "missingToolParameterWithPath": "Zoo tried to use {{toolName}} for '{{relPath}}' without value for required parameter '{{paramName}}'. Retrying...", diff --git a/src/i18n/locales/es/tools.json b/src/i18n/locales/es/tools.json index 7739a88834..07cc77d154 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...", "missingToolParameter": "Zoo intentó usar {{toolName}} sin valor para el parámetro requerido '{{paramName}}'. Reintentando...", "missingToolParameterWithPath": "Zoo intentó usar {{toolName}} para '{{relPath}}' sin valor para el parámetro requerido '{{paramName}}'. Reintentando...", diff --git a/src/i18n/locales/fr/tools.json b/src/i18n/locales/fr/tools.json index 9788adc4d9..d5d09dce47 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...", "missingToolParameter": "Zoo a tenté d'utiliser {{toolName}} sans valeur pour le paramètre requis '{{paramName}}'. Nouvelle tentative...", "missingToolParameterWithPath": "Zoo a tenté d'utiliser {{toolName}} pour '{{relPath}}' sans valeur pour le paramètre requis '{{paramName}}'. Nouvelle tentative...", diff --git a/src/i18n/locales/hi/tools.json b/src/i18n/locales/hi/tools.json index 867d801d03..69cf79e6d2 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}}\"। पुनः प्रयास कर रहा है...", "missingToolParameter": "Zoo ने आवश्यक पैरामीटर '{{paramName}}' के मान के बिना {{toolName}} का उपयोग करने का प्रयास किया। पुनः प्रयास कर रहा है...", "missingToolParameterWithPath": "Zoo ने '{{relPath}}' के लिए आवश्यक पैरामीटर '{{paramName}}' के मान के बिना {{toolName}} का उपयोग करने का प्रयास किया। पुनः प्रयास कर रहा है...", diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index 1a8362ce51..0f7c9b7fdf 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...", "missingToolParameter": "Zoo mencoba menggunakan {{toolName}} tanpa nilai untuk parameter wajib '{{paramName}}'. Mencoba lagi...", "missingToolParameterWithPath": "Zoo mencoba menggunakan {{toolName}} untuk '{{relPath}}' tanpa nilai untuk parameter wajib '{{paramName}}'. Mencoba lagi...", diff --git a/src/i18n/locales/it/tools.json b/src/i18n/locales/it/tools.json index 8df4f267bf..b0051abf59 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...", "missingToolParameter": "Zoo ha provato a utilizzare {{toolName}} senza valore per il parametro obbligatorio '{{paramName}}'. Nuovo tentativo...", "missingToolParameterWithPath": "Zoo ha provato a utilizzare {{toolName}} per '{{relPath}}' senza valore per il parametro obbligatorio '{{paramName}}'. Nuovo tentativo...", diff --git a/src/i18n/locales/ja/tools.json b/src/i18n/locales/ja/tools.json index f8ed987d12..fa404f7576 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}}」。再試行中...", "missingToolParameter": "Zooが必須パラメータ '{{paramName}}' の値なしで {{toolName}} を使用しようとしました。再試行中...", "missingToolParameterWithPath": "Zooが '{{relPath}}' に対して必須パラメータ '{{paramName}}' の値なしで {{toolName}} を使用しようとしました。再試行中...", diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index 7149766678..39335eb6e4 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}}\". 다시 시도 중...", "missingToolParameter": "Zoo가 필수 매개변수 '{{paramName}}'의 값 없이 {{toolName}}을(를) 사용하려고 했습니다. 다시 시도 중...", "missingToolParameterWithPath": "Zoo가 '{{relPath}}'에 대해 필수 매개변수 '{{paramName}}'의 값 없이 {{toolName}}을(를) 사용하려고 했습니다. 다시 시도 중...", diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index f26f5bd68a..d68de44d80 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...", "missingToolParameter": "Zoo probeerde {{toolName}} te gebruiken zonder waarde voor de vereiste parameter '{{paramName}}'. Opnieuw proberen...", "missingToolParameterWithPath": "Zoo probeerde {{toolName}} te gebruiken voor '{{relPath}}' zonder waarde voor de vereiste parameter '{{paramName}}'. Opnieuw proberen...", diff --git a/src/i18n/locales/pl/tools.json b/src/i18n/locales/pl/tools.json index 64743eade9..f7876025fc 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...", "missingToolParameter": "Zoo próbował użyć {{toolName}} bez wartości dla wymaganego parametru '{{paramName}}'. Ponowna próba...", "missingToolParameterWithPath": "Zoo próbował użyć {{toolName}} dla '{{relPath}}' bez wartości dla wymaganego parametru '{{paramName}}'. Ponowna próba...", diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index 97b2581f04..3b423d3784 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...", "missingToolParameter": "Zoo tentou usar {{toolName}} sem valor para o parâmetro obrigatório '{{paramName}}'. Tentando novamente...", "missingToolParameterWithPath": "Zoo tentou usar {{toolName}} para '{{relPath}}' sem valor para o parâmetro obrigatório '{{paramName}}'. Tentando novamente...", diff --git a/src/i18n/locales/ru/tools.json b/src/i18n/locales/ru/tools.json index 42688f88bf..ff8db8fe2e 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}}\". Повторная попытка...", "missingToolParameter": "Zoo попытался использовать {{toolName}} без значения для обязательного параметра '{{paramName}}'. Повторная попытка...", "missingToolParameterWithPath": "Zoo попытался использовать {{toolName}} для '{{relPath}}' без значения для обязательного параметра '{{paramName}}'. Повторная попытка...", diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index 0d5813f2ad..4769a826b4 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...", "missingToolParameter": "Zoo, gerekli '{{paramName}}' parametresi için değer olmadan {{toolName}} kullanmaya çalıştı. Yeniden deneniyor...", "missingToolParameterWithPath": "Zoo, '{{relPath}}' için gerekli '{{paramName}}' parametresi için değer olmadan {{toolName}} kullanmaya çalıştı. Yeniden deneniyor...", diff --git a/src/i18n/locales/vi/tools.json b/src/i18n/locales/vi/tools.json index 301fc6a3dc..d50dd50c65 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...", "missingToolParameter": "Zoo đã cố gắng sử dụng {{toolName}} mà không có giá trị cho tham số bắt buộc '{{paramName}}'. Đang thử lại...", "missingToolParameterWithPath": "Zoo đã cố gắng sử dụng {{toolName}} cho '{{relPath}}' mà không có giá trị cho tham số bắt buộc '{{paramName}}'. Đang thử lại...", diff --git a/src/i18n/locales/zh-CN/tools.json b/src/i18n/locales/zh-CN/tools.json index 80342c1891..2af580bb6a 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}}\"。正在重试...", "missingToolParameter": "Zoo 尝试使用 {{toolName}} 但未提供必需参数 '{{paramName}}' 的值。正在重试...", "missingToolParameterWithPath": "Zoo 尝试对 '{{relPath}}' 使用 {{toolName}} 但未提供必需参数 '{{paramName}}' 的值。正在重试...", diff --git a/src/i18n/locales/zh-TW/tools.json b/src/i18n/locales/zh-TW/tools.json index e090c2225c..75c8047945 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}}」。正在重試...", "missingToolParameter": "Zoo 嘗試使用 {{toolName}} 但未提供必要參數 '{{paramName}}' 的值。正在重試...", "missingToolParameterWithPath": "Zoo 嘗試對 '{{relPath}}' 使用 {{toolName}} 但未提供必要參數 '{{paramName}}' 的值。正在重試...", diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 714b507774..feef64947d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -15,6 +15,7 @@ import { OpenAiModelsMessageType, RouterModelsMessageType, VsCodeLmModelsMessageType, + DEFAULT_TOOL_REPETITION_SOFT_LIMIT, } from "@roo-code/types" import { @@ -98,6 +99,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" @@ -822,7 +825,37 @@ const ApiOptions = ({ ? apiConfiguration.consecutiveMistakeLimit : DEFAULT_CONSECUTIVE_MISTAKE_LIMIT } - onChange={(value) => setApiConfigurationField("consecutiveMistakeLimit", value)} + onChange={(value) => { + setApiConfigurationField("consecutiveMistakeLimit", value) + // Re-clamp the soft limit so it never ends up at or above the new + // hard limit, which would make the soft-block path unreachable. + const currentSoft = + apiConfiguration.toolRepetitionSoftLimit !== undefined + ? apiConfiguration.toolRepetitionSoftLimit + : DEFAULT_TOOL_REPETITION_SOFT_LIMIT + const clampedSoft = clampToolRepetitionSoftLimit(currentSoft, value) + if (clampedSoft !== currentSoft) { + setApiConfigurationField("toolRepetitionSoftLimit", clampedSoft) + } + }} + /> + + setApiConfigurationField( + "toolRepetitionSoftLimit", + clampToolRepetitionSoftLimit( + value, + apiConfiguration.consecutiveMistakeLimit !== undefined + ? apiConfiguration.consecutiveMistakeLimit + : DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + ), + ) + } /> {selectedProvider === providerIdentifiers.poe && ( void +} + +export const ToolRepetitionLimitControl = ({ + softValue, + onSoftChange, +}: ToolRepetitionLimitControlProps) => { + const { t } = useAppTranslation() + + const resolvedSoft = softValue ?? DEFAULT_TOOL_REPETITION_SOFT_LIMIT + + const label = t("settings:providers.toolRepetitionSoftLimit.label") + + 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__/ApiOptions.interactions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx index e96340ad4f..9000cbbb17 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx @@ -1,5 +1,10 @@ import { act, fireEvent, render, screen, within } from "@/utils/test-utils" -import { bedrockDefaultModelId, providerIdentifiers, type ProviderSettings } from "@roo-code/types" +import { + bedrockDefaultModelId, + DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + providerIdentifiers, + type ProviderSettings, +} from "@roo-code/types" import type { ChangeEventHandler, InputHTMLAttributes, ReactNode } from "react" import { requestLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels" @@ -167,6 +172,13 @@ vi.mock("@/components/ui", () => ({ SelectItem: ({ value, children }: { value?: string; children?: ReactNode }) => ( ), + Slider: ({ value, onValueChange }: { value?: number[]; onValueChange?: (value: number[]) => void }) => ( + onValueChange?.([Number(event.target.value)])} + /> + ), })) const renderApiOptions = (props: Partial = {}) => @@ -516,6 +528,103 @@ describe("ApiOptions interactions", () => { expect(setApiConfigurationField).toHaveBeenCalledWith("consecutiveMistakeLimit", 7) }) + it("re-clamps the soft limit when a lower consecutive mistake limit makes it unreachable", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { consecutiveMistakeLimit: 5, toolRepetitionSoftLimit: 4 }, + setApiConfigurationField, + }) + + // Lowering the hard limit to 2 forces the soft limit (4) down to hardLimit - 1 (1). + fireEvent.change(within(screen.getByTestId("consecutive-mistake-limit-control")).getByRole("slider"), { + target: { value: "2" }, + }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("consecutiveMistakeLimit", 2) + expect(setApiConfigurationField).toHaveBeenCalledWith("toolRepetitionSoftLimit", 1) + }) + + it("does not touch the soft limit when the new hard limit keeps it reachable", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { consecutiveMistakeLimit: 5, toolRepetitionSoftLimit: 2 }, + setApiConfigurationField, + }) + + // Raising the hard limit leaves the soft limit (2) untouched. + fireEvent.change(within(screen.getByTestId("consecutive-mistake-limit-control")).getByRole("slider"), { + target: { value: "8" }, + }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("consecutiveMistakeLimit", 8) + expect( + setApiConfigurationField.mock.calls.filter(([field]) => field === "toolRepetitionSoftLimit"), + ).toEqual([]) + }) + + it("falls back to the default soft limit when re-clamping and none is configured", () => { + const setApiConfigurationField = vi.fn() + // No toolRepetitionSoftLimit is configured, so the onChange handler must + // fall back to DEFAULT_TOOL_REPETITION_SOFT_LIMIT before re-clamping. + renderApiOptions({ + apiConfiguration: { consecutiveMistakeLimit: 5 }, + setApiConfigurationField, + }) + + // Lowering the hard limit to 1 forces even the default soft limit down to + // hardLimit - 1 (0), proving the undefined fallback branch is exercised. + fireEvent.change(within(screen.getByTestId("consecutive-mistake-limit-control")).getByRole("slider"), { + target: { value: "1" }, + }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("consecutiveMistakeLimit", 1) + expect(setApiConfigurationField).toHaveBeenCalledWith("toolRepetitionSoftLimit", 0) + }) + + it("falls back to the default hard limit when clamping the soft limit and none is configured", () => { + const setApiConfigurationField = vi.fn() + // No consecutiveMistakeLimit is configured, so onSoftChange must fall back + // to DEFAULT_CONSECUTIVE_MISTAKE_LIMIT when clamping the requested value. + renderApiOptions({ + apiConfiguration: { toolRepetitionSoftLimit: 1 }, + setApiConfigurationField, + }) + + const mistakeSlider = within(screen.getByTestId("consecutive-mistake-limit-control")).getByRole("slider") + const softSlider = screen.getAllByRole("slider").find((slider) => slider !== mistakeSlider) + expect(softSlider).toBeDefined() + + // Requesting a very large soft limit is clamped against the default hard + // limit, so the resulting value is DEFAULT_CONSECUTIVE_MISTAKE_LIMIT - 1. + fireEvent.change(softSlider!, { target: { value: "9" } }) + + const softCalls = setApiConfigurationField.mock.calls.filter(([field]) => field === "toolRepetitionSoftLimit") + expect(softCalls).toHaveLength(1) + const clampedValue = softCalls[0][1] as number + // With no hard limit configured, the fallback default (3) clamps the + // requested soft limit to exactly hardLimit - 1. + expect(clampedValue).toBe(DEFAULT_CONSECUTIVE_MISTAKE_LIMIT - 1) + }) + + it("updates and clamps the tool repetition soft limit from its control", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { consecutiveMistakeLimit: 3, toolRepetitionSoftLimit: 1 }, + setApiConfigurationField, + }) + + // The tool repetition soft-limit slider is the range input that is not + // inside the mocked consecutive-mistake-limit control. + const mistakeSlider = within(screen.getByTestId("consecutive-mistake-limit-control")).getByRole("slider") + const softSlider = screen.getAllByRole("slider").find((slider) => slider !== mistakeSlider) + expect(softSlider).toBeDefined() + + // Requesting a soft limit of 9 is clamped to hardLimit - 1 (2). + fireEvent.change(softSlider!, { target: { value: "9" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("toolRepetitionSoftLimit", 2) + }) + it("renders and updates the Poe base URL in advanced settings", () => { const setApiConfigurationField = vi.fn() renderApiOptions({ 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..4412bdec0d --- /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 }: { value: number[]; onValueChange: (value: number[]) => void }) => ( + 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..df7910f8bc --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/toolRepetitionLimits.spec.ts @@ -0,0 +1,78 @@ +// 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) + } + } + }) + + describe("ApiOptions save-flow integration", () => { + it("clamps the soft limit when the soft slider is moved to/above the hard limit", () => { + const hardLimit = 3 + // User drags the soft slider up to 8 while the hard limit is 3. + const saved = clampToolRepetitionSoftLimit(8, hardLimit) + expect(saved).toBe(2) + expect(saved).toBeLessThan(hardLimit) + }) + + it("re-clamps a previously valid soft limit when the hard limit is lowered", () => { + // Soft limit was validly set to 4 while the hard limit was 6. + const previousSoft = 4 + // User lowers the hard limit to 3, which would leave soft (4) >= hard (3). + const newHardLimit = 3 + const reclamped = clampToolRepetitionSoftLimit(previousSoft, newHardLimit) + expect(reclamped).toBe(2) + expect(reclamped).toBeLessThan(newHardLimit) + }) + + it("leaves the soft limit untouched when it remains valid after a hard-limit change", () => { + const previousSoft = 2 + const newHardLimit = 5 + const reclamped = clampToolRepetitionSoftLimit(previousSoft, newHardLimit) + expect(reclamped).toBe(previousSoft) + }) + }) +}) 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/components/ui/slider.tsx b/webview-ui/src/components/ui/slider.tsx index 6d3b14cbef..03f76fe7e6 100644 --- a/webview-ui/src/components/ui/slider.tsx +++ b/webview-ui/src/components/ui/slider.tsx @@ -6,7 +6,7 @@ import { cn } from "@/lib/utils" const Slider = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +>(({ className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...props }, ref) => ( diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 39b585a6b1..c006fce3cf 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -666,6 +666,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 cf6106a6dd..d6d4c53d81 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -666,6 +666,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 3ada77ccc3..56404be7a4 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -749,10 +749,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 edd35629b9..d039db6dd4 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -666,6 +666,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 fbd2512e42..957c8a5d06 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -666,6 +666,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 c6dffef9a3..11be1ab860 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -666,6 +666,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 1ca1c4035b..fc2001e7cc 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -666,6 +666,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 12e801aa49..6db59e55f7 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -666,6 +666,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 72f0b4e54f..e420e7070f 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -666,6 +666,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 8f7b1dbbcb..e6479c2027 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -666,6 +666,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 9751728c80..7aa633559d 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -666,6 +666,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 5fd8bdfe32..a856bc9240 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -666,6 +666,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 2b23f147b0..8b85518e03 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -666,6 +666,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 c684696317..1f32953ca5 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -666,6 +666,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 cd4556c7db..75265a0f9d 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -666,6 +666,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 b81ad24a2f..2257f18abe 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -666,6 +666,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 53ec69eb80..91e7f3d9c2 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -666,6 +666,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 312b005843..c1df6c85d1 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -693,6 +693,10 @@ "unlimitedDescription": "已啟用無限重試(自動繼續)。對話方塊將永遠不會出現。", "warning": "⚠️ 設定為 0 允許無限重試,這可能會消耗大量 API 使用量" }, + "toolRepetitionSoftLimit": { + "label": "重複軟警告閾值", + "description": "在連續這麼多次相同的工具呼叫後,該工具將被封鎖,並要求 AI 說明重試理由——無需你參與。設定為 0 可停用軟警告。" + }, "reasoningEffort": { "label": "模型推理強度", "none": "無",