diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 6c7d0a4b4b6..371416ef229 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -64,6 +64,8 @@ export type ModelParameter = z.infer export const isModelParameter = (value: string): value is ModelParameter => modelParameters.includes(value as ModelParameter) +import { editToolVariantSchema } from "./tool.js" + /** * ModelInfo */ @@ -120,6 +122,9 @@ export const modelInfoSchema = z.object({ // These tools will be added if they belong to an allowed group in the current mode // Cannot force-add tools from groups the mode doesn't allow includedTools: z.array(z.string()).optional(), + // Edit tool variant - determines which edit tool schema is presented to the LLM + // Each variant has a schema optimized for different LLM families (defaults to "roo") + editToolVariant: editToolVariantSchema.optional(), /** * Service tiers with pricing information. * Each tier can have a name (for OpenAI service tiers) and pricing overrides. diff --git a/packages/types/src/providers/gemini.ts b/packages/types/src/providers/gemini.ts index 17aa16db272..01e089e96fb 100644 --- a/packages/types/src/providers/gemini.ts +++ b/packages/types/src/providers/gemini.ts @@ -15,8 +15,8 @@ export const geminiModels = { supportsPromptCache: true, supportsReasoningEffort: ["low", "high"], reasoningEffort: "low", - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], supportsTemperature: true, defaultTemperature: 1, inputPrice: 4.0, @@ -43,8 +43,8 @@ export const geminiModels = { supportsPromptCache: true, supportsReasoningEffort: ["minimal", "low", "medium", "high"], reasoningEffort: "medium", - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], supportsTemperature: true, defaultTemperature: 1, inputPrice: 0.3, @@ -60,8 +60,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, cacheReadsPrice: 0.625, @@ -91,8 +91,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, cacheReadsPrice: 0.625, @@ -121,8 +121,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, cacheReadsPrice: 0.625, @@ -149,8 +149,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 2.5, // This is the pricing for prompts above 200k tokens. outputPrice: 15, cacheReadsPrice: 0.625, @@ -181,8 +181,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.3, outputPrice: 2.5, cacheReadsPrice: 0.075, @@ -197,8 +197,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.3, outputPrice: 2.5, cacheReadsPrice: 0.075, @@ -213,8 +213,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.3, outputPrice: 2.5, cacheReadsPrice: 0.075, @@ -231,8 +231,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.1, outputPrice: 0.4, cacheReadsPrice: 0.025, @@ -247,8 +247,8 @@ export const geminiModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.1, outputPrice: 0.4, cacheReadsPrice: 0.025, diff --git a/packages/types/src/providers/minimax.ts b/packages/types/src/providers/minimax.ts index 7152946f7f1..834deba1f12 100644 --- a/packages/types/src/providers/minimax.ts +++ b/packages/types/src/providers/minimax.ts @@ -15,8 +15,7 @@ export const minimaxModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["search_and_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", preserveReasoning: true, inputPrice: 0.3, outputPrice: 1.2, @@ -32,8 +31,7 @@ export const minimaxModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["search_and_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", preserveReasoning: true, inputPrice: 0.3, outputPrice: 1.2, @@ -49,8 +47,7 @@ export const minimaxModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["search_and_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", preserveReasoning: true, inputPrice: 0.3, outputPrice: 1.2, diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index 47b883e0e10..1d83cff4311 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -11,8 +11,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, promptCacheRetention: "24h", @@ -31,8 +31,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, promptCacheRetention: "24h", @@ -54,8 +54,8 @@ export const openAiNativeModels = { contextWindow: 128_000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, inputPrice: 1.75, @@ -68,8 +68,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, promptCacheRetention: "24h", @@ -91,8 +91,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, promptCacheRetention: "24h", @@ -110,8 +110,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, promptCacheRetention: "24h", @@ -128,8 +128,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["minimal", "low", "medium", "high"], @@ -150,8 +150,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["minimal", "low", "medium", "high"], @@ -172,8 +172,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["low", "medium", "high"], @@ -190,8 +190,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["minimal", "low", "medium", "high"], @@ -209,8 +209,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, inputPrice: 1.25, @@ -223,8 +223,8 @@ export const openAiNativeModels = { contextWindow: 1_047_576, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, inputPrice: 2, @@ -240,8 +240,8 @@ export const openAiNativeModels = { contextWindow: 1_047_576, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, inputPrice: 0.4, @@ -257,8 +257,8 @@ export const openAiNativeModels = { contextWindow: 1_047_576, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, inputPrice: 0.1, @@ -483,8 +483,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["minimal", "low", "medium", "high"], @@ -505,8 +505,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["minimal", "low", "medium", "high"], @@ -527,8 +527,8 @@ export const openAiNativeModels = { contextWindow: 400000, supportsNativeTools: true, defaultToolProtocol: "native", - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["minimal", "low", "medium", "high"], diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index 384b78de4db..0307fe6f660 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -15,8 +15,8 @@ export const vertexModels = { supportsPromptCache: true, supportsReasoningEffort: ["low", "high"], reasoningEffort: "low", - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], supportsTemperature: true, defaultTemperature: 1, inputPrice: 4.0, @@ -43,8 +43,8 @@ export const vertexModels = { supportsPromptCache: true, supportsReasoningEffort: ["minimal", "low", "medium", "high"], reasoningEffort: "medium", - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], supportsTemperature: true, defaultTemperature: 1, inputPrice: 0.3, @@ -59,8 +59,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.15, outputPrice: 3.5, maxThinkingTokens: 24_576, @@ -74,8 +74,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.15, outputPrice: 0.6, }, @@ -86,8 +86,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.3, outputPrice: 2.5, cacheReadsPrice: 0.075, @@ -102,8 +102,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: false, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.15, outputPrice: 3.5, maxThinkingTokens: 24_576, @@ -117,8 +117,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: false, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.15, outputPrice: 0.6, }, @@ -129,8 +129,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 2.5, outputPrice: 15, }, @@ -141,8 +141,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 2.5, outputPrice: 15, }, @@ -153,8 +153,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 2.5, outputPrice: 15, maxThinkingTokens: 32_768, @@ -167,8 +167,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 2.5, outputPrice: 15, maxThinkingTokens: 32_768, @@ -196,8 +196,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: false, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0, outputPrice: 0, }, @@ -208,8 +208,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: false, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0, outputPrice: 0, }, @@ -220,8 +220,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.15, outputPrice: 0.6, }, @@ -232,8 +232,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: false, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.075, outputPrice: 0.3, }, @@ -244,8 +244,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: false, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0, outputPrice: 0, }, @@ -256,8 +256,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.075, outputPrice: 0.3, }, @@ -268,8 +268,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: false, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 1.25, outputPrice: 5, }, @@ -463,8 +463,8 @@ export const vertexModels = { supportsNativeTools: true, defaultToolProtocol: "native", supportsPromptCache: true, - includedTools: ["write_file", "edit_file"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", + includedTools: ["write_file"], inputPrice: 0.1, outputPrice: 0.4, cacheReadsPrice: 0.025, diff --git a/packages/types/src/providers/xai.ts b/packages/types/src/providers/xai.ts index 23acb487aac..b6cf125d69d 100644 --- a/packages/types/src/providers/xai.ts +++ b/packages/types/src/providers/xai.ts @@ -18,8 +18,7 @@ export const xaiModels = { cacheWritesPrice: 0.02, cacheReadsPrice: 0.02, description: "xAI's Grok Code Fast model with 256K context window", - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", }, "grok-4-1-fast-reasoning": { maxTokens: 65_536, @@ -34,8 +33,7 @@ export const xaiModels = { cacheReadsPrice: 0.05, description: "xAI's Grok 4.1 Fast model with 2M context window, optimized for high-performance agentic tool calling with reasoning", - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", }, "grok-4-1-fast-non-reasoning": { maxTokens: 65_536, @@ -50,8 +48,7 @@ export const xaiModels = { cacheReadsPrice: 0.05, description: "xAI's Grok 4.1 Fast model with 2M context window, optimized for high-performance agentic tool calling", - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", }, "grok-4-fast-reasoning": { maxTokens: 65_536, @@ -66,8 +63,7 @@ export const xaiModels = { cacheReadsPrice: 0.05, description: "xAI's Grok 4 Fast model with 2M context window, optimized for high-performance agentic tool calling with reasoning", - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", }, "grok-4-fast-non-reasoning": { maxTokens: 65_536, @@ -82,8 +78,7 @@ export const xaiModels = { cacheReadsPrice: 0.05, description: "xAI's Grok 4 Fast model with 2M context window, optimized for high-performance agentic tool calling", - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", }, "grok-4-0709": { maxTokens: 8192, @@ -97,8 +92,7 @@ export const xaiModels = { cacheWritesPrice: 0.75, cacheReadsPrice: 0.75, description: "xAI's Grok-4 model with 256K context window", - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", }, "grok-3-mini": { maxTokens: 8192, @@ -114,8 +108,7 @@ export const xaiModels = { description: "xAI's Grok-3 mini model with 128K context window", supportsReasoningEffort: ["low", "high"], reasoningEffort: "low", - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", }, "grok-3": { maxTokens: 8192, @@ -129,7 +122,6 @@ export const xaiModels = { cacheWritesPrice: 0.75, cacheReadsPrice: 0.75, description: "xAI's Grok-3 model with 128K context window", - includedTools: ["search_replace"], - excludedTools: ["apply_diff"], + editToolVariant: "gemini", }, } as const satisfies Record diff --git a/packages/types/src/providers/zai.ts b/packages/types/src/providers/zai.ts index 93cf9bb23bc..a8e4f0c4bf9 100644 --- a/packages/types/src/providers/zai.ts +++ b/packages/types/src/providers/zai.ts @@ -18,6 +18,7 @@ export const internationalZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.6, outputPrice: 2.2, cacheWritesPrice: 0, @@ -32,6 +33,7 @@ export const internationalZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.2, outputPrice: 1.1, cacheWritesPrice: 0, @@ -46,6 +48,7 @@ export const internationalZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 2.2, outputPrice: 8.9, cacheWritesPrice: 0, @@ -60,6 +63,7 @@ export const internationalZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 1.1, outputPrice: 4.5, cacheWritesPrice: 0, @@ -73,6 +77,7 @@ export const internationalZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0, outputPrice: 0, cacheWritesPrice: 0, @@ -86,6 +91,7 @@ export const internationalZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.6, outputPrice: 1.8, cacheWritesPrice: 0, @@ -100,6 +106,7 @@ export const internationalZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.6, outputPrice: 2.2, cacheWritesPrice: 0, @@ -114,6 +121,7 @@ export const internationalZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", supportsReasoningEffort: ["disable", "medium"], reasoningEffort: "medium", preserveReasoning: true, @@ -131,6 +139,7 @@ export const internationalZAiModels = { supportsPromptCache: false, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.1, outputPrice: 0.1, cacheWritesPrice: 0, @@ -149,6 +158,7 @@ export const mainlandZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.29, outputPrice: 1.14, cacheWritesPrice: 0, @@ -163,6 +173,7 @@ export const mainlandZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.1, outputPrice: 0.6, cacheWritesPrice: 0, @@ -177,6 +188,7 @@ export const mainlandZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.29, outputPrice: 1.14, cacheWritesPrice: 0, @@ -191,6 +203,7 @@ export const mainlandZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.1, outputPrice: 0.6, cacheWritesPrice: 0, @@ -204,6 +217,7 @@ export const mainlandZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0, outputPrice: 0, cacheWritesPrice: 0, @@ -217,6 +231,7 @@ export const mainlandZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.29, outputPrice: 0.93, cacheWritesPrice: 0, @@ -231,6 +246,7 @@ export const mainlandZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", inputPrice: 0.29, outputPrice: 1.14, cacheWritesPrice: 0, @@ -245,6 +261,7 @@ export const mainlandZAiModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + editToolVariant: "gemini", supportsReasoningEffort: ["disable", "medium"], reasoningEffort: "medium", preserveReasoning: true, @@ -257,7 +274,7 @@ export const mainlandZAiModels = { }, } as const satisfies Record -export const ZAI_DEFAULT_TEMPERATURE = 0.6 +export const ZAI_DEFAULT_TEMPERATURE = 1 export const zaiApiLineConfigs = { international_coding: { diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index be3f49c40f2..2f7403bc515 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -10,6 +10,18 @@ export const toolGroupsSchema = z.enum(toolGroups) export type ToolGroup = z.infer +/** + * EditToolVariant + * + * Determines which edit tool schema is presented to the LLM. + * Each variant has a schema optimized for different LLM families. + */ +export const editToolVariants = ["roo", "anthropic", "grok", "gemini", "codex"] as const + +export const editToolVariantSchema = z.enum(editToolVariants) + +export type EditToolVariant = z.infer + /** * ToolName */ @@ -18,11 +30,17 @@ export const toolNames = [ "execute_command", "read_file", "write_to_file", + // apply_diff is kept for XML protocol backward compatibility "apply_diff", - "search_and_replace", - "search_replace", + // Unified edit tool for native protocol (variant selected via modelInfo.editToolVariant) "edit_file", - "apply_patch", + // Internal edit tool variant names (used by native protocol, presented to LLM as "edit_file") + "edit_file_roo", + "edit_file_anthropic", + "edit_file_grok", + "edit_file_gemini", + "edit_file_codex", + // Other tools "search_files", "list_files", "browser_action", diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 1ebdd684943..970baeddcd4 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -161,7 +161,7 @@ describe("OpenRouterHandler", () => { expect(result.temperature).toBe(0) }) - it("adds excludedTools and includedTools for OpenAI models", async () => { + it("sets editToolVariant to codex for OpenAI models", async () => { const handler = new OpenRouterHandler({ openRouterApiKey: "test-key", openRouterModelId: "openai/gpt-4o", @@ -169,12 +169,10 @@ describe("OpenRouterHandler", () => { const result = await handler.fetchModel() expect(result.id).toBe("openai/gpt-4o") - expect(result.info.excludedTools).toContain("apply_diff") - expect(result.info.excludedTools).toContain("write_to_file") - expect(result.info.includedTools).toContain("apply_patch") + expect(result.info.editToolVariant).toBe("codex") }) - it("merges excludedTools and includedTools with existing values for OpenAI models", async () => { + it("sets editToolVariant to codex for OpenAI models while preserving existing excludedTools/includedTools", async () => { const handler = new OpenRouterHandler({ openRouterApiKey: "test-key", openRouterModelId: "openai/o1", @@ -182,18 +180,14 @@ describe("OpenRouterHandler", () => { const result = await handler.fetchModel() expect(result.id).toBe("openai/o1") - // Should have the new exclusions - expect(result.info.excludedTools).toContain("apply_diff") - expect(result.info.excludedTools).toContain("write_to_file") - // Should preserve existing exclusions + // Should have editToolVariant set + expect(result.info.editToolVariant).toBe("codex") + // Should preserve existing exclusions/inclusions from model info expect(result.info.excludedTools).toContain("existing_excluded") - // Should have the new inclusions - expect(result.info.includedTools).toContain("apply_patch") - // Should preserve existing inclusions expect(result.info.includedTools).toContain("existing_included") }) - it("does not add excludedTools or includedTools for non-OpenAI models", async () => { + it("sets editToolVariant to anthropic for Claude models", async () => { const handler = new OpenRouterHandler({ openRouterApiKey: "test-key", openRouterModelId: "anthropic/claude-sonnet-4", @@ -201,9 +195,8 @@ describe("OpenRouterHandler", () => { const result = await handler.fetchModel() expect(result.id).toBe("anthropic/claude-sonnet-4") - // Should NOT have the tool exclusions/inclusions - expect(result.info.excludedTools).toBeUndefined() - expect(result.info.includedTools).toBeUndefined() + // Should have editToolVariant set to anthropic + expect(result.info.editToolVariant).toBe("anthropic") }) }) diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 119e869e6f3..827c217a587 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -284,7 +284,7 @@ describe("XAIHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, - temperature: 0, + temperature: 0.7, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/fetchers/__tests__/roo.spec.ts b/src/api/providers/fetchers/__tests__/roo.spec.ts index cd86be0b692..7e4ca06ff35 100644 --- a/src/api/providers/fetchers/__tests__/roo.spec.ts +++ b/src/api/providers/fetchers/__tests__/roo.spec.ts @@ -741,8 +741,8 @@ describe("getRooModels", () => { output: "0.0002", }, settings: { - includedTools: ["apply_patch"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "codex", + excludedTools: ["write_to_file"], reasoningEffort: "high", }, }, @@ -756,8 +756,8 @@ describe("getRooModels", () => { const models = await getRooModels(baseUrl, apiKey) - expect(models["test/model-with-settings"].includedTools).toEqual(["apply_patch"]) - expect(models["test/model-with-settings"].excludedTools).toEqual(["apply_diff", "write_to_file"]) + expect(models["test/model-with-settings"].editToolVariant).toBe("codex") + expect(models["test/model-with-settings"].excludedTools).toEqual(["write_to_file"]) expect(models["test/model-with-settings"].reasoningEffort).toBe("high") }) @@ -824,14 +824,14 @@ describe("getRooModels", () => { }, // Plain settings for backward compatibility with old clients settings: { - includedTools: ["apply_patch"], + editToolVariant: "codex", excludedTools: ["write_to_file"], }, // Versioned settings keyed by version number (low version - always met) versionedSettings: { "1.0.0": { - includedTools: ["apply_patch", "search_replace"], - excludedTools: ["apply_diff", "write_to_file"], + editToolVariant: "gemini", + excludedTools: ["write_to_file", "browser_action"], }, }, }, @@ -846,8 +846,8 @@ describe("getRooModels", () => { const models = await getRooModels(baseUrl, apiKey) // Versioned settings should be used instead of plain settings - expect(models["test/versioned-model"].includedTools).toEqual(["apply_patch", "search_replace"]) - expect(models["test/versioned-model"].excludedTools).toEqual(["apply_diff", "write_to_file"]) +expect(models["test/versioned-model"].editToolVariant).toBe("gemini") + expect(models["test/versioned-model"].excludedTools).toEqual(["write_to_file", "browser_action"]) }) it("should use plain settings when no versioned settings version matches", async () => { @@ -870,12 +870,12 @@ describe("getRooModels", () => { output: "0.0002", }, settings: { - includedTools: ["apply_patch"], + editToolVariant: "codex", }, // Versioned settings keyed by very high version - never met versionedSettings: { "99.0.0": { - includedTools: ["apply_patch", "search_replace"], + editToolVariant: "gemini", }, }, }, @@ -890,7 +890,7 @@ describe("getRooModels", () => { const models = await getRooModels(baseUrl, apiKey) // Should use plain settings since no versioned settings match current version - expect(models["test/old-version-model"].includedTools).toEqual(["apply_patch"]) + expect(models["test/old-version-model"].editToolVariant).toBe("codex") }) it("should handle model with only versionedSettings and no plain settings", async () => { diff --git a/src/api/providers/utils/__tests__/router-tool-preferences.spec.ts b/src/api/providers/utils/__tests__/router-tool-preferences.spec.ts new file mode 100644 index 00000000000..8f18bc2f8e2 --- /dev/null +++ b/src/api/providers/utils/__tests__/router-tool-preferences.spec.ts @@ -0,0 +1,173 @@ +// npx vitest run api/providers/utils/__tests__/router-tool-preferences.spec.ts + +import type { ModelInfo } from "@roo-code/types" +import { applyRouterToolPreferences } from "../router-tool-preferences" + +describe("applyRouterToolPreferences", () => { + const baseModelInfo: ModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: false, + } + + describe("OpenAI models", () => { + it("should apply codex variant and exclude write_to_file for openai models", () => { + const result = applyRouterToolPreferences("openai/gpt-4", baseModelInfo) + + expect(result.editToolVariant).toBe("codex") + expect(result.excludedTools).toContain("write_to_file") + }) + + it("should not override existing editToolVariant for openai models", () => { + const info: ModelInfo = { ...baseModelInfo, editToolVariant: "gemini" } + const result = applyRouterToolPreferences("openai/gpt-4", info) + + expect(result.editToolVariant).toBe("gemini") + }) + + it("should preserve existing excludedTools and add write_to_file", () => { + const info: ModelInfo = { ...baseModelInfo, excludedTools: ["some_tool"] } + const result = applyRouterToolPreferences("openai/gpt-4", info) + + expect(result.excludedTools).toContain("some_tool") + expect(result.excludedTools).toContain("write_to_file") + }) + + it("should not duplicate write_to_file in excludedTools", () => { + const info: ModelInfo = { ...baseModelInfo, excludedTools: ["write_to_file"] } + const result = applyRouterToolPreferences("openai/gpt-4", info) + + expect(result.excludedTools?.filter((t) => t === "write_to_file").length).toBe(1) + }) + }) + + describe("Gemini models", () => { + it("should apply gemini variant and include write_file for gemini models", () => { + const result = applyRouterToolPreferences("google/gemini-2.5-pro", baseModelInfo) + + expect(result.editToolVariant).toBe("gemini") + expect(result.includedTools).toContain("write_file") + }) + + it("should not override existing editToolVariant for gemini models", () => { + const info: ModelInfo = { ...baseModelInfo, editToolVariant: "codex" } + const result = applyRouterToolPreferences("google/gemini-2.5-pro", info) + + expect(result.editToolVariant).toBe("codex") + }) + + it("should preserve existing includedTools and add write_file", () => { + const info: ModelInfo = { ...baseModelInfo, includedTools: ["some_tool"] } + const result = applyRouterToolPreferences("google/gemini-2.5-pro", info) + + expect(result.includedTools).toContain("some_tool") + expect(result.includedTools).toContain("write_file") + }) + + it("should not duplicate write_file in includedTools", () => { + const info: ModelInfo = { ...baseModelInfo, includedTools: ["write_file"] } + const result = applyRouterToolPreferences("google/gemini-2.5-pro", info) + + expect(result.includedTools?.filter((t) => t === "write_file").length).toBe(1) + }) + }) + + describe("xAI/Grok models", () => { + it("should apply grok variant for grok models", () => { + const result = applyRouterToolPreferences("xai/grok-2", baseModelInfo) + + expect(result.editToolVariant).toBe("grok") + }) + + it("should apply grok variant for models containing xai", () => { + const result = applyRouterToolPreferences("xai/grok-beta", baseModelInfo) + + expect(result.editToolVariant).toBe("grok") + }) + + it("should not override existing editToolVariant for grok models", () => { + const info: ModelInfo = { ...baseModelInfo, editToolVariant: "codex" } + const result = applyRouterToolPreferences("xai/grok-2", info) + + expect(result.editToolVariant).toBe("codex") + }) + }) + + describe("Claude/Anthropic models", () => { + it("should apply anthropic variant for claude models", () => { + const result = applyRouterToolPreferences("anthropic/claude-3.5-sonnet", baseModelInfo) + + expect(result.editToolVariant).toBe("anthropic") + }) + + it("should apply anthropic variant for models containing anthropic", () => { + const result = applyRouterToolPreferences("anthropic/claude-3-opus", baseModelInfo) + + expect(result.editToolVariant).toBe("anthropic") + }) + + it("should apply anthropic variant for models containing claude", () => { + const result = applyRouterToolPreferences("openrouter/claude-3-haiku", baseModelInfo) + + expect(result.editToolVariant).toBe("anthropic") + }) + + it("should not override existing editToolVariant for claude models", () => { + const info: ModelInfo = { ...baseModelInfo, editToolVariant: "codex" } + const result = applyRouterToolPreferences("anthropic/claude-3.5-sonnet", info) + + expect(result.editToolVariant).toBe("codex") + }) + }) + + describe("Unknown models", () => { + it("should not modify model info for unknown models", () => { + const result = applyRouterToolPreferences("some-provider/unknown-model", baseModelInfo) + + expect(result).toEqual(baseModelInfo) + }) + + it("should preserve all original properties for unknown models", () => { + const info: ModelInfo = { + ...baseModelInfo, + editToolVariant: "roo", + excludedTools: ["tool1"], + includedTools: ["tool2"], + } + const result = applyRouterToolPreferences("some-provider/unknown-model", info) + + expect(result).toEqual(info) + }) + }) + + describe("Edge cases", () => { + it("should handle empty modelId", () => { + const result = applyRouterToolPreferences("", baseModelInfo) + + expect(result).toEqual(baseModelInfo) + }) + + it("should handle modelId with multiple matching patterns (openai takes precedence via order)", () => { + // This is a contrived case - in reality modelIds wouldn't contain multiple provider names + const result = applyRouterToolPreferences("openai-gemini-hybrid", baseModelInfo) + + // openai matches first, then gemini adds its modifications + expect(result.editToolVariant).toBe("codex") // openai sets this first, gemini doesn't override + expect(result.excludedTools).toContain("write_to_file") + expect(result.includedTools).toContain("write_file") + }) + + it("should preserve other ModelInfo properties", () => { + const info: ModelInfo = { + ...baseModelInfo, + description: "Test model", + supportsNativeTools: true, + } + const result = applyRouterToolPreferences("openai/gpt-4", info) + + expect(result.description).toBe("Test model") + expect(result.supportsNativeTools).toBe(true) + }) + }) +}) diff --git a/src/api/providers/utils/router-tool-preferences.ts b/src/api/providers/utils/router-tool-preferences.ts index bb5ece3b96b..369f3058288 100644 --- a/src/api/providers/utils/router-tool-preferences.ts +++ b/src/api/providers/utils/router-tool-preferences.ts @@ -3,12 +3,13 @@ import type { ModelInfo } from "@roo-code/types" /** * Apply tool preferences for models accessed through dynamic routers (OpenRouter, Requesty). * - * Different model families perform better with specific tools: - * - OpenAI models: Better results with apply_patch instead of apply_diff/write_to_file - * - Gemini models: Higher quality results with write_file and edit_file + * Different model families perform better with specific edit tool schemas: + * - OpenAI models: Better results with the codex (patch) format + * - Gemini models: Higher quality results with the gemini (search/replace) format + * - xAI/Grok models: Better results with the grok format * * This function modifies the model info to apply these preferences consistently - * across all dynamic router providers. + * across all dynamic router providers via `editToolVariant`. * * @param modelId The model identifier (e.g., "openai/gpt-4", "google/gemini-2.5-pro") * @param info The original model info object @@ -17,23 +18,41 @@ import type { ModelInfo } from "@roo-code/types" export function applyRouterToolPreferences(modelId: string, info: ModelInfo): ModelInfo { let result = info - // For OpenAI models via routers, exclude write_to_file and apply_diff, and include apply_patch + // For OpenAI models via routers, use codex variant and exclude write_to_file // This matches the behavior of the native OpenAI provider if (modelId.includes("openai")) { result = { ...result, - excludedTools: [...new Set([...(result.excludedTools || []), "apply_diff", "write_to_file"])], - includedTools: [...new Set([...(result.includedTools || []), "apply_patch"])], + editToolVariant: result.editToolVariant ?? "codex", + excludedTools: [...new Set([...(result.excludedTools || []), "write_to_file"])], } } - // For Gemini models via routers, include write_file and edit_file + // For Gemini models via routers, use gemini variant // This matches the behavior of the native Gemini provider if (modelId.includes("gemini")) { result = { ...result, - excludedTools: [...new Set([...(result.excludedTools || []), "apply_diff"])], - includedTools: [...new Set([...(result.includedTools || []), "write_file", "edit_file"])], + editToolVariant: result.editToolVariant ?? "gemini", + includedTools: [...new Set([...(result.includedTools || []), "write_file"])], + } + } + + // For xAI/Grok models via routers, use grok variant + // This matches the behavior of the native xAI provider + if (modelId.includes("grok") || modelId.includes("xai")) { + result = { + ...result, + editToolVariant: result.editToolVariant ?? "grok", + } + } + + // For Claude/Anthropic models via routers, use anthropic variant + // This matches the behavior of the native Anthropic provider + if (modelId.includes("claude") || modelId.includes("anthropic")) { + result = { + ...result, + editToolVariant: result.editToolVariant ?? "anthropic", } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 29548e8cf39..b5c904b19d1 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -16,7 +16,7 @@ import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { handleOpenAIError } from "./utils/openai-error-handler" -const XAI_DEFAULT_TEMPERATURE = 0 +const XAI_DEFAULT_TEMPERATURE = 0.7 export class XAIHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 48c85a160ee..52b9662e162 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -497,43 +497,36 @@ export class NativeToolCallParser { } break - case "apply_patch": - if (partialArgs.patch !== undefined) { + case "edit_file": + // Handle unified edit_file with variant-aware argument detection + // Detect variant based on which arguments are present: + // - roo variant: { path, diff } + // - anthropic variant: { path, edits: [{ old_text, new_text }] } + // - grok/gemini variant: { file_path, old_string, new_string } + // - codex variant: { patch } + if (partialArgs.diff !== undefined) { + // Roo variant (apply_diff format) nativeArgs = { - patch: partialArgs.patch, + path: partialArgs.path, + diff: partialArgs.diff, } - } - break - - case "search_replace": - if ( - partialArgs.file_path !== undefined || - partialArgs.old_string !== undefined || - partialArgs.new_string !== undefined - ) { + } else if (partialArgs.edits !== undefined) { + // Anthropic variant (search_and_replace format) nativeArgs = { - file_path: partialArgs.file_path, - old_string: partialArgs.old_string, - new_string: partialArgs.new_string, + path: partialArgs.path, + edits: partialArgs.edits, } - } - break - - case "search_and_replace": - if (partialArgs.path !== undefined || partialArgs.operations !== undefined) { + } else if (partialArgs.patch !== undefined) { + // Codex variant (apply_patch format) nativeArgs = { - path: partialArgs.path, - operations: partialArgs.operations, + patch: partialArgs.patch, } - } - break - - case "edit_file": - if ( + } else if ( partialArgs.file_path !== undefined || partialArgs.old_string !== undefined || partialArgs.new_string !== undefined ) { + // Grok/Gemini variant (search_replace/edit_file format) nativeArgs = { file_path: partialArgs.file_path, old_string: partialArgs.old_string, @@ -660,15 +653,6 @@ export class NativeToolCallParser { } break - case "search_and_replace": - if (args.path !== undefined && args.operations !== undefined && Array.isArray(args.operations)) { - nativeArgs = { - path: args.path, - operations: args.operations, - } as NativeArgsFor - } - break - case "ask_followup_question": if (args.question !== undefined && args.follow_up !== undefined) { nativeArgs = { @@ -782,34 +766,36 @@ export class NativeToolCallParser { } break - case "apply_patch": - if (args.patch !== undefined) { + case "edit_file": + // Handle unified edit_file with variant-aware argument detection + // Detect variant based on which arguments are present: + // - roo variant: { path, diff } + // - anthropic variant: { path, edits: [{ old_text, new_text }] } + // - grok/gemini variant: { file_path, old_string, new_string } + // - codex variant: { patch } + if (args.diff !== undefined && args.path !== undefined) { + // Roo variant (apply_diff format) nativeArgs = { - patch: args.patch, + path: args.path, + diff: args.diff, } as NativeArgsFor - } - break - - case "search_replace": - if ( - args.file_path !== undefined && - args.old_string !== undefined && - args.new_string !== undefined - ) { + } else if (args.edits !== undefined && Array.isArray(args.edits) && args.path !== undefined) { + // Anthropic variant (search_and_replace format) nativeArgs = { - file_path: args.file_path, - old_string: args.old_string, - new_string: args.new_string, + path: args.path, + edits: args.edits, } as NativeArgsFor - } - break - - case "edit_file": - if ( + } else if (args.patch !== undefined) { + // Codex variant (apply_patch format) + nativeArgs = { + patch: args.patch, + } as NativeArgsFor + } else if ( args.file_path !== undefined && args.old_string !== undefined && args.new_string !== undefined ) { + // Grok/Gemini variant (search_replace/edit_file format) nativeArgs = { file_path: args.file_path, old_string: args.old_string, diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 0e81671cc15..061fa0eb14f 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -7,6 +7,26 @@ describe("NativeToolCallParser", () => { }) describe("parseToolCall", () => { + it("should resolve apply_patch to edit_file and preserve originalName", () => { + const toolCall = { + id: "toolu_apply_patch_123", + name: "apply_patch" as any, + arguments: JSON.stringify({ + patch: "*** Begin Patch\n*** End Patch", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.name).toBe("edit_file") + expect(result.originalName).toBe("apply_patch") + expect(result.nativeArgs).toEqual({ patch: "*** Begin Patch\n*** End Patch" }) + } + }) + describe("read_file tool", () => { it("should handle line_ranges as tuples (new format)", () => { const toolCall = { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-variants.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-variants.spec.ts new file mode 100644 index 00000000000..efdecacedaa --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-variants.spec.ts @@ -0,0 +1,144 @@ +// npx vitest run core/assistant-message/__tests__/presentAssistantMessage-tool-usage-variants.spec.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" + +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureException: vi.fn(), + }, + }, +})) + +const { mockEditToolHandle } = vi.hoisted(() => ({ + mockEditToolHandle: vi.fn(async (_task: unknown, _toolUse: unknown, callbacks: any) => { + callbacks.pushToolResult("ok") + }), +})) + +vi.mock("../../tools/EditFileRooTool", () => ({ + editFileRooTool: { handle: mockEditToolHandle }, +})) + +vi.mock("../../tools/EditFileAnthropicTool", () => ({ + editFileAnthropicTool: { handle: mockEditToolHandle }, +})) + +vi.mock("../../tools/EditFileGrokTool", () => ({ + editFileGrokTool: { handle: mockEditToolHandle }, +})) + +vi.mock("../../tools/EditFileGeminiTool", () => ({ + editFileGeminiTool: { handle: mockEditToolHandle }, +})) + +vi.mock("../../tools/EditFileCodexTool", () => ({ + editFileCodexTool: { handle: mockEditToolHandle }, +})) + +// Import AFTER mocks +import { TelemetryService } from "@roo-code/telemetry" +import { presentAssistantMessage } from "../presentAssistantMessage" + +describe("presentAssistantMessage - tool usage analytics names", () => { + let mockTask: any + + beforeEach(() => { + vi.clearAllMocks() + + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + diffEnabled: false, + consecutiveMistakeCount: 0, + consecutiveMistakeLimit: 3, + clineMessages: [], + currentStreamingDidCheckpoint: false, + checkpointSave: vi.fn().mockResolvedValue(undefined), + apiConfiguration: { apiProvider: "openai" }, + api: { + getModel: () => ({ id: "test-model", info: { editToolVariant: "roo" } }), + }, + browserSession: { + closeBrowser: vi.fn().mockResolvedValue(undefined), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [], experiments: {} }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + } + }) + + it.each([ + ["roo", "edit_file_roo"], + ["anthropic", "edit_file_anthropic"], + ["grok", "edit_file_grok"], + ["gemini", "edit_file_gemini"], + ["codex", "edit_file_codex"], + ] as const)("records derived analytics tool name for edit_file (%s)", async (variant, expected) => { + mockTask.api.getModel = () => ({ id: "test-model", info: { editToolVariant: variant } }) + + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_123", + name: "edit_file", + params: { path: "file.txt" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask) + + expect(mockTask.recordToolUsage).toHaveBeenCalledWith(expected) + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith( + mockTask.taskId, + expected, + expect.any(String), + ) + }) + + it("does not change other tool names", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_456", + name: "read_file", + params: { path: "file.txt" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask) + + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("read_file") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith( + mockTask.taskId, + "read_file", + expect.any(String), + ) + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index c1876b8cd08..312271b6ba6 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -2,7 +2,7 @@ import cloneDeep from "clone-deep" import { serializeError } from "serialize-error" import { Anthropic } from "@anthropic-ai/sdk" -import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" +import type { ToolName, ClineAsk, ToolProgressStatus, EditToolVariant } from "@roo-code/types" import { ConsecutiveMistakeError } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -23,10 +23,10 @@ import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/s import { shouldUseSingleFileRead, TOOL_PROTOCOL } from "@roo-code/types" import { writeToFileTool } from "../tools/WriteToFileTool" import { applyDiffTool } from "../tools/MultiApplyDiffTool" -import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool" -import { searchReplaceTool } from "../tools/SearchReplaceTool" -import { editFileTool } from "../tools/EditFileTool" -import { applyPatchTool } from "../tools/ApplyPatchTool" +import { editFileAnthropicTool } from "../tools/EditFileAnthropicTool" +import { editFileGrokTool } from "../tools/EditFileGrokTool" +import { editFileGeminiTool } from "../tools/EditFileGeminiTool" +import { editFileCodexTool } from "../tools/EditFileCodexTool" import { searchFilesTool } from "../tools/SearchFilesTool" import { browserActionTool } from "../tools/BrowserActionTool" import { executeCommandTool } from "../tools/ExecuteCommandTool" @@ -39,12 +39,28 @@ import { newTaskTool } from "../tools/NewTaskTool" import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { generateImageTool } from "../tools/GenerateImageTool" -import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" +import { editFileRooTool } from "../tools/EditFileRooTool" import { validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" +function getAnalyticsToolNameForToolUse(toolName: ToolName, editToolVariant: EditToolVariant): ToolName { + if (toolName !== "edit_file") { + return toolName + } + + const analyticsToolNameByVariant = { + roo: "edit_file_roo", + anthropic: "edit_file_anthropic", + grok: "edit_file_grok", + gemini: "edit_file_gemini", + codex: "edit_file_codex", + } satisfies Record + + return analyticsToolNameByVariant[editToolVariant] +} + /** * Processes and presents assistant message content to the user interface. * @@ -402,14 +418,19 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.regex}'${ block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" }]` - case "search_and_replace": - return `[${block.name} for '${block.params.path}']` - case "search_replace": - return `[${block.name} for '${block.params.file_path}']` - case "edit_file": - return `[${block.name} for '${block.params.file_path}']` - case "apply_patch": + case "edit_file": { + // Unified edit_file tool - path location depends on variant + // Gemini/Grok variants use file_path, Roo/Anthropic use path + const filePath = block.params.file_path || block.params.path + if (filePath) { + return `[${block.name} for '${filePath}']` + } + // Codex variant uses patch parameter + if (block.params.patch) { + return `[${block.name}]` + } return `[${block.name}]` + } case "list_files": return `[${block.name} for '${block.params.path}']` case "browser_action": @@ -702,8 +723,15 @@ export async function presentAssistantMessage(cline: Task) { } if (!block.partial) { - cline.recordToolUsage(block.name) - TelemetryService.instance.captureToolUsage(cline.taskId, block.name, toolProtocol) + let toolNameForAnalytics: ToolName = block.name + if (toolNameForAnalytics === "edit_file") { + const modelInfo = cline.api.getModel() + const editToolVariant: EditToolVariant = modelInfo?.info?.editToolVariant ?? "roo" + toolNameForAnalytics = getAnalyticsToolNameForToolUse(toolNameForAnalytics, editToolVariant) + } + + cline.recordToolUsage(toolNameForAnalytics) + TelemetryService.instance.captureToolUsage(cline.taskId, toolNameForAnalytics, toolProtocol) } // Validate tool use before execution - ONLY for complete (non-partial) blocks. @@ -723,7 +751,16 @@ export async function presentAssistantMessage(cline: Task) { block.name as ToolName, mode ?? defaultModeSlug, customModes ?? [], - { apply_diff: cline.diffEnabled }, + { + // diffEnabled should gate ALL edit operations (both XML legacy apply_diff and native edit_file) + apply_diff: cline.diffEnabled, + edit_file: cline.diffEnabled, + edit_file_roo: cline.diffEnabled, + edit_file_anthropic: cline.diffEnabled, + edit_file_grok: cline.diffEnabled, + edit_file_gemini: cline.diffEnabled, + edit_file_codex: cline.diffEnabled, + }, block.params, stateExperiments, includedTools, @@ -833,7 +870,7 @@ export async function presentAssistantMessage(cline: Task) { // Check if this tool call came from native protocol by checking for ID // Native calls always have IDs, XML calls never do if (toolProtocol === TOOL_PROTOCOL.NATIVE) { - await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + await editFileRooTool.handle(cline, block as ToolUse<"edit_file_roo">, { askApproval, handleError, pushToolResult, @@ -858,7 +895,7 @@ export async function presentAssistantMessage(cline: Task) { if (isMultiFileApplyDiffEnabled) { await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) } else { - await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + await editFileRooTool.handle(cline, block as ToolUse<"edit_file_roo">, { askApproval, handleError, pushToolResult, @@ -868,46 +905,77 @@ export async function presentAssistantMessage(cline: Task) { } break } - case "search_and_replace": - await checkpointSaveAndMark(cline) - await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, { - askApproval, - handleError, - pushToolResult, - removeClosingTag, - toolProtocol, - }) - break - case "search_replace": - await checkpointSaveAndMark(cline) - await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { - askApproval, - handleError, - pushToolResult, - removeClosingTag, - toolProtocol, - }) - break - case "edit_file": + case "edit_file": { + // Unified edit_file tool - route to correct handler based on editToolVariant await checkpointSaveAndMark(cline) - await editFileTool.handle(cline, block as ToolUse<"edit_file">, { - askApproval, - handleError, - pushToolResult, - removeClosingTag, - toolProtocol, - }) - break - case "apply_patch": - await checkpointSaveAndMark(cline) - await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { - askApproval, - handleError, - pushToolResult, - removeClosingTag, - toolProtocol, - }) + const modelInfo = cline.api.getModel() + const editToolVariant: EditToolVariant = modelInfo?.info?.editToolVariant ?? "roo" + + switch (editToolVariant) { + case "roo": + // Route to Roo variant (unified diff format) + await editFileRooTool.handle(cline, block as ToolUse<"edit_file_roo">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "anthropic": + // Route to Anthropic variant (multi-edit search/replace) + await editFileAnthropicTool.handle(cline, block as ToolUse<"edit_file_anthropic">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "grok": + // Route to Grok variant (single search/replace) + await editFileGrokTool.handle(cline, block as ToolUse<"edit_file_grok">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "gemini": + // Route to Gemini variant (search/replace with expected_replacements) + await editFileGeminiTool.handle(cline, block as ToolUse<"edit_file_gemini">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + case "codex": + // Route to Codex variant (unified patch format) + await editFileCodexTool.handle(cline, block as ToolUse<"edit_file_codex">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + default: { + // Should never happen, but default to roo variant + const _exhaustiveCheck: never = editToolVariant + await editFileRooTool.handle(cline, block as ToolUse<"edit_file_roo">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + } + } break + } case "read_file": // Check if this model should use the simplified single-file read tool // Only use simplified tool for XML protocol - native protocol works with standard tool diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap index 54df428abd3..0f5d4744649 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-diff-enabled-true.snap @@ -149,6 +149,48 @@ Example: Requesting to list all files in the current directory false +## write_to_file +Description: Request to write content to a file. This tool is primarily used for **creating new files** or for scenarios where a **complete rewrite of an existing file is intentionally required**. If the file exists, it will be overwritten. If it doesn't exist, it will be created. This tool will automatically create any directories needed to write the file. + +**Important:** You should prefer using other editing tools over write_to_file when making changes to existing files, since write_to_file is slower and cannot handle large files. Use write_to_file primarily for new file creation. + +When using this tool, use it directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code. + +When creating a new project, organize all new files within a dedicated project directory unless the user specifies otherwise. Structure the project logically, adhering to best practices for the specific type of project being created. + +Parameters: +- path: (required) The path of the file to write to (relative to the current workspace directory /test/path) +- content: (required) The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include line numbers in the content. + +Usage: + +File path here + +Your file content here + + + +Example: Writing a configuration file + +frontend-config.json + +{ + "apiEndpoint": "https://api.example.com", + "theme": { + "primaryColor": "#007bff", + "secondaryColor": "#6c757d", + "fontFamily": "Arial, sans-serif" + }, + "features": { + "darkMode": true, + "notifications": true, + "analytics": false + }, + "version": "1.0.0" +} + + + ## apply_diff Description: Request to apply PRECISE, TARGETED modifications to an existing file by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code. You can perform multiple distinct search and replace operations within a single `apply_diff` call by providing multiple SEARCH/REPLACE blocks in the `diff` parameter. This is the preferred way to make several targeted changes efficiently. @@ -237,48 +279,6 @@ Only use a single line of '=======' between search and replacement content, beca -## write_to_file -Description: Request to write content to a file. This tool is primarily used for **creating new files** or for scenarios where a **complete rewrite of an existing file is intentionally required**. If the file exists, it will be overwritten. If it doesn't exist, it will be created. This tool will automatically create any directories needed to write the file. - -**Important:** You should prefer using other editing tools over write_to_file when making changes to existing files, since write_to_file is slower and cannot handle large files. Use write_to_file primarily for new file creation. - -When using this tool, use it directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code. - -When creating a new project, organize all new files within a dedicated project directory unless the user specifies otherwise. Structure the project logically, adhering to best practices for the specific type of project being created. - -Parameters: -- path: (required) The path of the file to write to (relative to the current workspace directory /test/path) -- content: (required) The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified. Do NOT include line numbers in the content. - -Usage: - -File path here - -Your file content here - - - -Example: Writing a configuration file - -frontend-config.json - -{ - "apiEndpoint": "https://api.example.com", - "theme": { - "primaryColor": "#007bff", - "secondaryColor": "#6c757d", - "fontFamily": "Arial, sans-serif" - }, - "features": { - "darkMode": true, - "notifications": true, - "analytics": false - }, - "version": "1.0.0" -} - - - ## ask_followup_question Description: Ask the user a question to gather additional information needed to complete the task. Use when you need clarification or more details to proceed effectively. diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index 50db6984f22..594fab86a69 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -4,7 +4,14 @@ import type { ModeConfig, ModelInfo } from "@roo-code/types" import { filterNativeToolsForMode, filterMcpToolsForMode, applyModelToolCustomization } from "../filter-tools-for-mode" import * as toolsModule from "../../../../shared/tools" +// NOTE: Edit tools are now unified under the "edit_file" name. The input tools +// use variant names (edit_file_roo, edit_file_anthropic, etc.) and the filter +// function renames the selected variant to "edit_file" in the output. +// Legacy tool names (apply_diff, search_and_replace, etc.) are filtered out +// and replaced with the unified "edit_file" tool. + describe("filterNativeToolsForMode", () => { + // Include all edit tool variants to mirror the production native tools array. const mockNativeTools: OpenAI.Chat.ChatCompletionTool[] = [ { type: "function", @@ -25,8 +32,40 @@ describe("filterNativeToolsForMode", () => { { type: "function", function: { - name: "apply_diff", - description: "Apply diff", + name: "edit_file_roo", + description: "Edit file (Roo variant)", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "edit_file_anthropic", + description: "Edit file (Anthropic variant)", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "edit_file_grok", + description: "Edit file (Grok variant)", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "edit_file_gemini", + description: "Edit file (Gemini variant)", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "edit_file_codex", + description: "Edit file (Codex variant)", parameters: {}, }, }, @@ -87,9 +126,10 @@ describe("filterNativeToolsForMode", () => { // Should include read tools expect(toolNames).toContain("read_file") - // Should NOT include edit tools + // Should NOT include edit tools (no edit group) expect(toolNames).not.toContain("write_to_file") - expect(toolNames).not.toContain("apply_diff") + expect(toolNames).not.toContain("edit_file") + expect(toolNames).not.toContain("edit_file_roo") // Should NOT include command tools expect(toolNames).not.toContain("execute_command") @@ -115,15 +155,41 @@ describe("filterNativeToolsForMode", () => { const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) // Should include all tools (code mode has all groups) + // Note: edit tool variants get renamed to a single display name. + // Default is "edit_file" (roo variant), but codex can be presented as "apply_patch". expect(toolNames).toContain("read_file") expect(toolNames).toContain("write_to_file") - expect(toolNames).toContain("apply_diff") + expect(toolNames).toContain("edit_file") // Unified edit tool (default) + expect(toolNames).not.toContain("edit_file_roo") // Variant name should be renamed expect(toolNames).toContain("execute_command") expect(toolNames).toContain("browser_action") expect(toolNames).toContain("ask_followup_question") expect(toolNames).toContain("attempt_completion") }) + it("should present codex edit tool variant as apply_patch when editToolVariant is codex", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "code", [codeMode], {}, undefined, { + modelInfo: { + contextWindow: 100000, + supportsPromptCache: false, + editToolVariant: "codex", + }, + }) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + expect(toolNames).toContain("apply_patch") + expect(toolNames).not.toContain("edit_file") + expect(toolNames).not.toContain("edit_file_codex") + }) + it("should always include always-available tools regardless of mode groups", () => { const restrictiveMode: ModeConfig = { slug: "restrictive", @@ -485,36 +551,37 @@ describe("filterMcpToolsForMode", () => { } it("should return original tools when modelInfo is undefined", () => { - const tools = new Set(["read_file", "write_to_file", "apply_diff"]) + const tools = new Set(["read_file", "write_to_file", "edit_file"]) const result = applyModelToolCustomization(tools, codeMode, undefined) expect(result.allowedTools).toEqual(tools) }) it("should exclude tools specified in excludedTools", () => { - const tools = new Set(["read_file", "write_to_file", "apply_diff"]) + // Note: edit_file is the unified edit tool name + const tools = new Set(["read_file", "write_to_file", "edit_file"]) const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - excludedTools: ["apply_diff"], + excludedTools: ["edit_file"], } const result = applyModelToolCustomization(tools, codeMode, modelInfo) expect(result.allowedTools.has("read_file")).toBe(true) expect(result.allowedTools.has("write_to_file")).toBe(true) - expect(result.allowedTools.has("apply_diff")).toBe(false) + expect(result.allowedTools.has("edit_file")).toBe(false) }) it("should exclude multiple tools", () => { - const tools = new Set(["read_file", "write_to_file", "apply_diff", "execute_command"]) + const tools = new Set(["read_file", "write_to_file", "edit_file", "execute_command"]) const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - excludedTools: ["apply_diff", "write_to_file"], + excludedTools: ["edit_file", "write_to_file"], } const result = applyModelToolCustomization(tools, codeMode, modelInfo) expect(result.allowedTools.has("read_file")).toBe(true) expect(result.allowedTools.has("execute_command")).toBe(true) expect(result.allowedTools.has("write_to_file")).toBe(false) - expect(result.allowedTools.has("apply_diff")).toBe(false) + expect(result.allowedTools.has("edit_file")).toBe(false) }) it("should include tools only if they belong to allowed groups", () => { @@ -522,12 +589,12 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - includedTools: ["write_to_file", "apply_diff"], // Both in edit group + includedTools: ["write_to_file", "edit_file"], // Both in edit group } const result = applyModelToolCustomization(tools, codeMode, modelInfo) expect(result.allowedTools.has("read_file")).toBe(true) expect(result.allowedTools.has("write_to_file")).toBe(true) - expect(result.allowedTools.has("apply_diff")).toBe(true) + expect(result.allowedTools.has("edit_file")).toBe(true) }) it("should NOT include tools from groups not allowed by mode", () => { @@ -535,28 +602,29 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - includedTools: ["write_to_file", "apply_diff"], // Edit group tools + includedTools: ["write_to_file", "edit_file"], // Edit group tools } // Architect mode doesn't have edit group const result = applyModelToolCustomization(tools, architectMode, modelInfo) expect(result.allowedTools.has("read_file")).toBe(true) expect(result.allowedTools.has("write_to_file")).toBe(false) // Not in allowed groups - expect(result.allowedTools.has("apply_diff")).toBe(false) // Not in allowed groups + expect(result.allowedTools.has("edit_file")).toBe(false) // Not in allowed groups }) it("should apply both exclude and include operations", () => { - const tools = new Set(["read_file", "write_to_file", "apply_diff"]) + // Note: edit_file is the unified edit tool. + const tools = new Set(["read_file", "write_to_file", "execute_command"]) const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - excludedTools: ["apply_diff"], - includedTools: ["search_and_replace"], // Another edit tool (customTool) + excludedTools: ["execute_command"], + includedTools: ["edit_file"], // Unified edit tool } const result = applyModelToolCustomization(tools, codeMode, modelInfo) expect(result.allowedTools.has("read_file")).toBe(true) expect(result.allowedTools.has("write_to_file")).toBe(true) - expect(result.allowedTools.has("apply_diff")).toBe(false) // Excluded - expect(result.allowedTools.has("search_and_replace")).toBe(true) // Included + expect(result.allowedTools.has("execute_command")).toBe(false) // Excluded + expect(result.allowedTools.has("edit_file")).toBe(true) // Included }) it("should handle empty excludedTools and includedTools arrays", () => { @@ -576,7 +644,7 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - excludedTools: ["apply_diff", "nonexistent_tool"], + excludedTools: ["edit_file", "nonexistent_tool"], } const result = applyModelToolCustomization(tools, codeMode, modelInfo) expect(result.allowedTools.has("read_file")).toBe(true) @@ -701,32 +769,48 @@ describe("filterMcpToolsForMode", () => { { type: "function", function: { - name: "apply_diff", - description: "Apply diff", + name: "edit_file_roo", + description: "Edit file (Roo variant)", parameters: {}, }, }, { type: "function", function: { - name: "execute_command", - description: "Execute command", + name: "edit_file_anthropic", + description: "Edit file (Anthropic variant)", parameters: {}, }, }, { type: "function", function: { - name: "search_and_replace", - description: "Search and replace", + name: "edit_file_grok", + description: "Edit file (Grok variant)", parameters: {}, }, }, { type: "function", function: { - name: "edit_file", - description: "Edit file", + name: "edit_file_gemini", + description: "Edit file (Gemini variant)", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "edit_file_codex", + description: "Edit file (Codex variant)", + parameters: {}, + }, + }, + { + type: "function", + function: { + name: "execute_command", + description: "Execute command", parameters: {}, }, }, @@ -743,7 +827,7 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - excludedTools: ["apply_diff"], + excludedTools: ["edit_file"], } const filtered = filterNativeToolsForMode(mockNativeTools, "code", [codeMode], {}, undefined, { @@ -754,7 +838,7 @@ describe("filterMcpToolsForMode", () => { expect(toolNames).toContain("read_file") expect(toolNames).toContain("write_to_file") - expect(toolNames).not.toContain("apply_diff") // Excluded by model + expect(toolNames).not.toContain("edit_file") // Excluded by model }) it("should include tools when model specifies includedTools from allowed groups", () => { @@ -768,7 +852,7 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - includedTools: ["search_and_replace"], // Edit group customTool + includedTools: ["edit_file"], // Unified edit tool } const filtered = filterNativeToolsForMode(mockNativeTools, "limited", [modeWithOnlyRead], {}, undefined, { @@ -777,7 +861,7 @@ describe("filterMcpToolsForMode", () => { const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) - expect(toolNames).toContain("search_and_replace") // Included by model + expect(toolNames).toContain("edit_file") // Included by model (renamed from edit_file_roo) }) it("should NOT include tools from groups not allowed by mode", () => { @@ -791,7 +875,7 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - includedTools: ["write_to_file", "apply_diff"], // Edit group tools + includedTools: ["write_to_file", "edit_file"], // Edit group tools } const filtered = filterNativeToolsForMode(mockNativeTools, "architect", [architectMode], {}, undefined, { @@ -802,7 +886,7 @@ describe("filterMcpToolsForMode", () => { expect(toolNames).toContain("read_file") expect(toolNames).not.toContain("write_to_file") // Not in mode's allowed groups - expect(toolNames).not.toContain("apply_diff") // Not in mode's allowed groups + expect(toolNames).not.toContain("edit_file") // Not in mode's allowed groups }) it("should combine excludedTools and includedTools", () => { @@ -816,8 +900,8 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - excludedTools: ["apply_diff"], - includedTools: ["search_and_replace"], + excludedTools: ["execute_command"], + includedTools: ["edit_file"], } const filtered = filterNativeToolsForMode(mockNativeTools, "code", [codeMode], {}, undefined, { @@ -827,8 +911,8 @@ describe("filterMcpToolsForMode", () => { const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) expect(toolNames).toContain("write_to_file") - expect(toolNames).toContain("search_and_replace") // Included - expect(toolNames).not.toContain("apply_diff") // Excluded + expect(toolNames).toContain("edit_file") // Included + expect(toolNames).not.toContain("execute_command") // Excluded }) it("should honor included aliases while respecting exclusions", () => { @@ -842,7 +926,7 @@ describe("filterMcpToolsForMode", () => { const modelInfo: ModelInfo = { contextWindow: 100000, supportsPromptCache: false, - excludedTools: ["apply_diff"], + excludedTools: ["execute_command"], includedTools: ["edit_file", "write_file"], } @@ -853,9 +937,8 @@ describe("filterMcpToolsForMode", () => { const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) expect(toolNames).toContain("edit_file") - expect(toolNames).toContain("write_file") - expect(toolNames).not.toContain("apply_diff") - expect(toolNames).not.toContain("write_to_file") + expect(toolNames).toContain("write_file") // Alias for write_to_file + expect(toolNames).not.toContain("execute_command") }) }) }) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index f296c1b5c50..e8dfb106d62 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -1,5 +1,5 @@ import type OpenAI from "openai" -import type { ModeConfig, ToolName, ToolGroup, ModelInfo } from "@roo-code/types" +import type { ModeConfig, ToolName, ToolGroup, ModelInfo, EditToolVariant } from "@roo-code/types" import { getModeBySlug, getToolsForMode } from "../../../shared/modes" import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../../shared/tools" import { defaultModeSlug } from "../../../shared/modes" @@ -7,6 +7,32 @@ import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" +/** + * Central edit tool configuration. + * + * `toolName` is the internal native tool name. + * `displayName` is the name shown to (and called by) the model. + * + * By default, edit tool variants are presented as "edit_file". + * The codex variant is presented as "apply_patch" so: + * - the model calls `apply_patch` + * - the tool is stored in API conversation history as `apply_patch` + * - execution is still routed through the canonical `edit_file` tool via aliases + */ +const EDIT_TOOL_VARIANTS: Record = { + roo: { toolName: "edit_file_roo", displayName: "edit_file" }, + anthropic: { toolName: "edit_file_anthropic", displayName: "edit_file" }, + grok: { toolName: "edit_file_grok", displayName: "edit_file" }, + gemini: { toolName: "edit_file_gemini", displayName: "edit_file" }, + codex: { toolName: "edit_file_codex", displayName: "apply_patch" }, +} + +/** + * All edit tool variant names that should be filtered. + * Only one of these (based on editToolVariant) will be included and renamed to the configured display name. + */ +const ALL_EDIT_TOOL_VARIANTS = new Set(Object.values(EDIT_TOOL_VARIANTS).map((variant) => variant.toolName)) + /** * Reverse lookup map - maps alias name to canonical tool name. * Built once at module load from the central TOOL_ALIASES constant. @@ -296,16 +322,35 @@ export function filterNativeToolsForMode( allowedToolNames.delete("browser_action") } - // Conditionally exclude apply_diff if diffs are disabled - if (settings?.diffEnabled === false) { - allowedToolNames.delete("apply_diff") - } - // Conditionally exclude access_mcp_resource if MCP is not enabled or there are no resources if (!mcpHub || !hasAnyMcpResources(mcpHub)) { allowedToolNames.delete("access_mcp_resource") } + // Handle edit tool variant selection: + for (const variantTool of ALL_EDIT_TOOL_VARIANTS) { + allowedToolNames.delete(variantTool) + } + + // Determine which edit tool variant to use (default: "roo") + const editToolVariant: EditToolVariant = modelInfo?.editToolVariant ?? "roo" + const selectedEditToolName = EDIT_TOOL_VARIANTS[editToolVariant].toolName + const editToolDisplayName = EDIT_TOOL_VARIANTS[editToolVariant].displayName + + // Check if diffs are disabled - if so, skip edit tool entirely + const diffEnabled = settings?.diffEnabled !== false + + // Check if mode has "edit" group (required for edit tools) + const allowedGroups = new Set( + modeConfig.groups.map((groupEntry) => (Array.isArray(groupEntry) ? groupEntry[0] : groupEntry)), + ) + const modeHasEditGroup = allowedGroups.has("edit") + + // Check if edit_file is excluded by model config + const isEditFileExcluded = modelInfo?.excludedTools?.some( + (tool) => resolveToolAlias(tool) === "edit_file" || tool === "edit_file", + ) + // Filter native tools based on allowed tool names and apply alias renames const filteredTools: OpenAI.Chat.ChatCompletionTool[] = [] @@ -313,6 +358,23 @@ export function filterNativeToolsForMode( // Handle both ChatCompletionTool and ChatCompletionCustomTool if ("function" in tool && tool.function) { const toolName = tool.function.name + + // Special handling for edit tool variants + if (ALL_EDIT_TOOL_VARIANTS.has(toolName)) { + // Only include if: + // 1. This is the selected variant + // 2. Diffs are enabled + // 3. Mode has "edit" group + // 4. edit_file is not excluded by model config + if (toolName === selectedEditToolName && diffEnabled && modeHasEditGroup && !isEditFileExcluded) { + // Rename the selected variant to the configured display name. + // The tool will still execute via the unified `edit_file` tool name through aliases. + filteredTools.push(getOrCreateRenamedTool(tool, editToolDisplayName)) + } + continue + } + + // Regular tool processing if (allowedToolNames.has(toolName)) { // Check if this tool should be renamed to an alias const aliasName = aliasRenames.get(toolName) diff --git a/src/core/prompts/tools/native-tools/edit_file_anthropic.ts b/src/core/prompts/tools/native-tools/edit_file_anthropic.ts new file mode 100644 index 00000000000..0ca895a1ec3 --- /dev/null +++ b/src/core/prompts/tools/native-tools/edit_file_anthropic.ts @@ -0,0 +1,46 @@ +import type OpenAI from "openai" + +const EDIT_FILE_ANTHROPIC_DESCRIPTION = `Edit an existing file by applying one or more exact string replacements.` + +const edit_file_anthropic = { + type: "function", + function: { + name: "edit_file_anthropic", + description: EDIT_FILE_ANTHROPIC_DESCRIPTION, + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Path to the file to edit", + }, + edits: { + type: "array", + description: "List of edits to apply sequentially", + items: { + type: "object", + properties: { + old_text: { + type: "string", + description: "Exact text to be replaced", + }, + new_text: { + type: "string", + description: "Replacement text", + }, + }, + required: ["old_text", "new_text"], + }, + minItems: 1, + }, + }, + required: ["path", "edits"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool + +export default edit_file_anthropic + +// Backward compatibility export +export const search_and_replace = edit_file_anthropic diff --git a/src/core/prompts/tools/native-tools/apply_patch.ts b/src/core/prompts/tools/native-tools/edit_file_codex.ts similarity index 79% rename from src/core/prompts/tools/native-tools/apply_patch.ts rename to src/core/prompts/tools/native-tools/edit_file_codex.ts index 47ba60400df..856e0a25496 100644 --- a/src/core/prompts/tools/native-tools/apply_patch.ts +++ b/src/core/prompts/tools/native-tools/edit_file_codex.ts @@ -1,6 +1,6 @@ import type OpenAI from "openai" -const apply_patch_DESCRIPTION = `Apply patches to files using a stripped-down, file-oriented diff format. This tool supports creating new files, deleting files, and updating existing files with precise changes. +const EDIT_FILE_CODEX_DESCRIPTION = `Apply patches to files using a stripped-down, file-oriented diff format. This tool supports creating new files, deleting files, and updating existing files with precise changes. The patch format uses a simple, human-readable structure: @@ -38,11 +38,11 @@ Example patch: *** Delete File: obsolete.txt *** End Patch` -const apply_patch = { +const edit_file_codex = { type: "function", function: { - name: "apply_patch", - description: apply_patch_DESCRIPTION, + name: "edit_file_codex", + description: EDIT_FILE_CODEX_DESCRIPTION, parameters: { type: "object", properties: { @@ -58,4 +58,7 @@ const apply_patch = { }, } satisfies OpenAI.Chat.ChatCompletionTool -export default apply_patch +export default edit_file_codex + +// Backward compatibility export +export const apply_patch = edit_file_codex diff --git a/src/core/prompts/tools/native-tools/edit_file.ts b/src/core/prompts/tools/native-tools/edit_file_gemini.ts similarity index 87% rename from src/core/prompts/tools/native-tools/edit_file.ts rename to src/core/prompts/tools/native-tools/edit_file_gemini.ts index ed6a59f3e1a..bc4056e9e77 100644 --- a/src/core/prompts/tools/native-tools/edit_file.ts +++ b/src/core/prompts/tools/native-tools/edit_file_gemini.ts @@ -1,6 +1,6 @@ import type OpenAI from "openai" -const EDIT_FILE_DESCRIPTION = `Use this tool to replace text in an existing file, or create a new file. +const EDIT_FILE_GEMINI_DESCRIPTION = `Use this tool to replace text in an existing file, or create a new file. This tool performs literal string replacement with support for multiple occurrences. @@ -31,11 +31,11 @@ CRITICAL REQUIREMENTS: 4. NO ESCAPING: Provide the literal text - do not escape special characters.` -const edit_file = { +const edit_file_gemini = { type: "function", function: { - name: "edit_file", - description: EDIT_FILE_DESCRIPTION, + name: "edit_file_gemini", + description: EDIT_FILE_GEMINI_DESCRIPTION, parameters: { type: "object", properties: { @@ -67,4 +67,7 @@ const edit_file = { }, } satisfies OpenAI.Chat.ChatCompletionTool -export default edit_file +export default edit_file_gemini + +// Backward compatibility export (original name was edit_file) +export { edit_file_gemini as edit_file } diff --git a/src/core/prompts/tools/native-tools/search_replace.ts b/src/core/prompts/tools/native-tools/edit_file_grok.ts similarity index 87% rename from src/core/prompts/tools/native-tools/search_replace.ts rename to src/core/prompts/tools/native-tools/edit_file_grok.ts index cc3b0e5269e..394dccec28e 100644 --- a/src/core/prompts/tools/native-tools/search_replace.ts +++ b/src/core/prompts/tools/native-tools/edit_file_grok.ts @@ -1,6 +1,6 @@ import type OpenAI from "openai" -const SEARCH_REPLACE_DESCRIPTION = `Use this tool to propose a search and replace operation on an existing file. +const EDIT_FILE_GROK_DESCRIPTION = `Use this tool to propose a search and replace operation on an existing file. The tool will replace ONE occurrence of old_string with new_string in the specified file. @@ -19,11 +19,11 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL: - If multiple instances exist, gather enough context to uniquely identify each one - Plan separate tool calls for each instance` -const search_replace = { +const edit_file_grok = { type: "function", function: { - name: "search_replace", - description: SEARCH_REPLACE_DESCRIPTION, + name: "edit_file_grok", + description: EDIT_FILE_GROK_DESCRIPTION, parameters: { type: "object", properties: { @@ -48,4 +48,7 @@ const search_replace = { }, } satisfies OpenAI.Chat.ChatCompletionTool -export default search_replace +export default edit_file_grok + +// Backward compatibility export +export const search_replace = edit_file_grok diff --git a/src/core/prompts/tools/native-tools/apply_diff.ts b/src/core/prompts/tools/native-tools/edit_file_roo.ts similarity index 58% rename from src/core/prompts/tools/native-tools/apply_diff.ts rename to src/core/prompts/tools/native-tools/edit_file_roo.ts index 3938e4886a0..7522567b66c 100644 --- a/src/core/prompts/tools/native-tools/apply_diff.ts +++ b/src/core/prompts/tools/native-tools/edit_file_roo.ts @@ -1,6 +1,6 @@ import type OpenAI from "openai" -const APPLY_DIFF_DESCRIPTION = `Apply precise, targeted modifications to an existing file using one or more search/replace blocks. This tool is for surgical edits only; the 'SEARCH' block must exactly match the existing content, including whitespace and indentation. To make multiple targeted changes, provide multiple SEARCH/REPLACE blocks in the 'diff' parameter. Use the 'read_file' tool first if you are not confident in the exact content to search for.` +const EDIT_FILE_ROO_DESCRIPTION = `Apply precise, targeted modifications to an existing file using one or more search/replace blocks. This tool is for surgical edits only; the 'SEARCH' block must exactly match the existing content, including whitespace and indentation. To make multiple targeted changes, provide multiple SEARCH/REPLACE blocks in the 'diff' parameter. Use the 'read_file' tool first if you are not confident in the exact content to search for.` const DIFF_PARAMETER_DESCRIPTION = `A string containing one or more search/replace blocks defining the changes. The ':start_line:' is required and indicates the starting line number of the original content. You must not add a start line for the replacement content. Each block must follow this format: <<<<<<< SEARCH @@ -11,11 +11,11 @@ const DIFF_PARAMETER_DESCRIPTION = `A string containing one or more search/repla [new content to replace with] >>>>>>> REPLACE` -export const apply_diff = { +export const edit_file_roo = { type: "function", function: { - name: "apply_diff", - description: APPLY_DIFF_DESCRIPTION, + name: "edit_file_roo", + description: EDIT_FILE_ROO_DESCRIPTION, parameters: { type: "object", properties: { @@ -33,3 +33,6 @@ export const apply_diff = { }, }, } satisfies OpenAI.Chat.ChatCompletionTool + +// Backward compatibility export +export const apply_diff = edit_file_roo diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 79302a39f31..9942ff5a5f8 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -1,7 +1,7 @@ import type OpenAI from "openai" import accessMcpResource from "./access_mcp_resource" -import { apply_diff } from "./apply_diff" -import applyPatch from "./apply_patch" +import { edit_file_roo, apply_diff } from "./edit_file_roo" +import edit_file_codex, { apply_patch } from "./edit_file_codex" import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" @@ -13,9 +13,9 @@ import listFiles from "./list_files" import newTask from "./new_task" import { createReadFileTool } from "./read_file" import runSlashCommand from "./run_slash_command" -import searchAndReplace from "./search_and_replace" -import searchReplace from "./search_replace" -import edit_file from "./edit_file" +import edit_file_anthropic, { search_and_replace } from "./edit_file_anthropic" +import edit_file_grok, { search_replace } from "./edit_file_grok" +import edit_file_gemini, { edit_file } from "./edit_file_gemini" import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" @@ -25,16 +25,47 @@ export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" /** - * Get native tools array, optionally customizing based on settings. + * Edit tool variant types - determines which edit tool schema is presented to the LLM + */ +export type EditToolVariant = "roo" | "anthropic" | "grok" | "gemini" | "codex" + +/** + * All edit tool definitions mapped by variant + */ +export const EDIT_TOOL_VARIANTS: Record = { + roo: edit_file_roo, + anthropic: edit_file_anthropic, + grok: edit_file_grok, + gemini: edit_file_gemini, + codex: edit_file_codex, +} + +/** + * Get the edit tool definition for a specific variant + * @param variant The edit tool variant to use + * @returns The tool definition for that variant + */ +export function getEditToolForVariant(variant: EditToolVariant): OpenAI.Chat.ChatCompletionTool { + return EDIT_TOOL_VARIANTS[variant] +} + +/** + * Get native tools array, including all edit tool variants. + * The filterNativeToolsForMode function will select the appropriate variant + * based on modelInfo.editToolVariant and rename it to "edit_file". * * @param partialReadsEnabled - Whether to include line_ranges support in read_file tool (default: true) - * @returns Array of native tool definitions + * @returns Array of native tool definitions (including all edit tool variants) */ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool[] { return [ accessMcpResource, - apply_diff, - applyPatch, + // All edit tool variants - filterNativeToolsForMode will select one and rename to "edit_file" + edit_file_roo, + edit_file_anthropic, + edit_file_grok, + edit_file_gemini, + edit_file_codex, askFollowupQuestion, attemptCompletion, browserAction, @@ -46,9 +77,6 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat newTask, createReadFileTool(partialReadsEnabled), runSlashCommand, - searchAndReplace, - searchReplace, - edit_file, searchFiles, switchMode, updateTodoList, @@ -57,4 +85,21 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat } // Backward compatibility: export default tools with line ranges enabled +// Note: filterNativeToolsForMode will select the edit tool variant based on modelInfo export const nativeTools = getNativeTools(true) + +// Re-export individual tools for backward compatibility +export { + // New names + edit_file_roo, + edit_file_anthropic, + edit_file_grok, + edit_file_gemini, + edit_file_codex, + // Old names (aliases) + apply_diff, + search_and_replace, + search_replace, + edit_file, + apply_patch, +} diff --git a/src/core/prompts/tools/native-tools/search_and_replace.ts b/src/core/prompts/tools/native-tools/search_and_replace.ts deleted file mode 100644 index ce785b6a165..00000000000 --- a/src/core/prompts/tools/native-tools/search_and_replace.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type OpenAI from "openai" - -const SEARCH_AND_REPLACE_DESCRIPTION = `Apply precise, targeted modifications to an existing file using search and replace operations. This tool is for surgical edits only; provide an array of operations where each operation specifies the exact text to search for and what to replace it with. The search text must exactly match the existing content, including whitespace and indentation.` - -const search_and_replace = { - type: "function", - function: { - name: "search_and_replace", - description: SEARCH_AND_REPLACE_DESCRIPTION, - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "The path of the file to modify, relative to the current workspace directory.", - }, - operations: { - type: "array", - description: "Array of search and replace operations to perform on the file.", - items: { - type: "object", - properties: { - search: { - type: "string", - description: - "The exact text to find in the file. Must match exactly, including whitespace.", - }, - replace: { - type: "string", - description: "The text to replace the search text with.", - }, - }, - required: ["search", "replace"], - }, - minItems: 1, - }, - }, - required: ["path", "operations"], - additionalProperties: false, - }, - }, -} satisfies OpenAI.Chat.ChatCompletionTool - -export default search_and_replace diff --git a/src/core/task/__tests__/native-tools-filtering.spec.ts b/src/core/task/__tests__/native-tools-filtering.spec.ts index 761fe6e1ec2..d3c853e7afb 100644 --- a/src/core/task/__tests__/native-tools-filtering.spec.ts +++ b/src/core/task/__tests__/native-tools-filtering.spec.ts @@ -41,8 +41,9 @@ describe("Native Tools Filtering by Mode", () => { ALWAYS_AVAILABLE_TOOLS.forEach((tool) => architectAllowedTools.add(tool)) // Architect should NOT have edit tools + // Note: apply_diff is now a legacy name; edit tools are now in customTools + // and the unified "edit_file" is added via filterNativeToolsForMode expect(architectAllowedTools.has("write_to_file")).toBe(false) - expect(architectAllowedTools.has("apply_diff")).toBe(false) // Architect SHOULD have read tools expect(architectAllowedTools.has("read_file")).toBe(true) @@ -68,8 +69,9 @@ describe("Native Tools Filtering by Mode", () => { ALWAYS_AVAILABLE_TOOLS.forEach((tool) => codeAllowedTools.add(tool)) // Code SHOULD have edit tools + // Note: apply_diff is now a legacy name; the unified edit tool "edit_file" + // is added via filterNativeToolsForMode, not from TOOL_GROUPS.edit.tools expect(codeAllowedTools.has("write_to_file")).toBe(true) - expect(codeAllowedTools.has("apply_diff")).toBe(true) // Code SHOULD have read tools expect(codeAllowedTools.has("read_file")).toBe(true) diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/EditFileAnthropicTool.ts similarity index 70% rename from src/core/tools/SearchAndReplaceTool.ts rename to src/core/tools/EditFileAnthropicTool.ts index 7d03a6a22c3..2a16ca7a0ff 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/EditFileAnthropicTool.ts @@ -14,73 +14,73 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" -interface SearchReplaceOperation { - search: string - replace: string +interface EditOperation { + old_text: string + new_text: string } interface SearchAndReplaceParams { path: string - operations: SearchReplaceOperation[] + edits: EditOperation[] } -export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { - readonly name = "search_and_replace" as const +export class SearchAndReplaceTool extends BaseTool<"edit_file_anthropic"> { + readonly name = "edit_file_anthropic" as const parseLegacy(params: Partial>): SearchAndReplaceParams { - // Parse operations from JSON string if provided - let operations: SearchReplaceOperation[] = [] - if (params.operations) { + // Parse edits from JSON string if provided + let edits: EditOperation[] = [] + if (params.edits) { try { - operations = JSON.parse(params.operations) + edits = JSON.parse(params.edits) } catch { - operations = [] + edits = [] } } return { path: params.path || "", - operations, + edits, } } async execute(params: SearchAndReplaceParams, task: Task, callbacks: ToolCallbacks): Promise { - const { path: relPath, operations } = params + const { path: relPath, edits } = params const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks try { // Validate required parameters if (!relPath) { task.consecutiveMistakeCount++ - task.recordToolError("search_and_replace") - pushToolResult(await task.sayAndCreateMissingParamError("search_and_replace", "path")) + task.recordToolError("edit_file_anthropic") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file_anthropic", "path")) return } - if (!operations || !Array.isArray(operations) || operations.length === 0) { + if (!edits || !Array.isArray(edits) || edits.length === 0) { task.consecutiveMistakeCount++ - task.recordToolError("search_and_replace") + task.recordToolError("edit_file_anthropic") pushToolResult( formatResponse.toolError( - "Missing or empty 'operations' parameter. At least one search/replace operation is required.", + "Missing or empty 'edits' parameter. At least one edit operation is required.", ), ) return } - // Validate each operation has search and replace fields - for (let i = 0; i < operations.length; i++) { - const op = operations[i] - if (!op.search) { + // Validate each edit has old_text and new_text fields + for (let i = 0; i < edits.length; i++) { + const op = edits[i] + if (!op.old_text) { task.consecutiveMistakeCount++ - task.recordToolError("search_and_replace") - pushToolResult(formatResponse.toolError(`Operation ${i + 1} is missing the 'search' field.`)) + task.recordToolError("edit_file_anthropic") + pushToolResult(formatResponse.toolError(`Edit ${i + 1} is missing the 'old_text' field.`)) return } - if (op.replace === undefined) { + if (op.new_text === undefined) { task.consecutiveMistakeCount++ - task.recordToolError("search_and_replace") - pushToolResult(formatResponse.toolError(`Operation ${i + 1} is missing the 'replace' field.`)) + task.recordToolError("edit_file_anthropic") + pushToolResult(formatResponse.toolError(`Edit ${i + 1} is missing the 'new_text' field.`)) return } } @@ -101,7 +101,7 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { task.consecutiveMistakeCount++ - task.recordToolError("search_and_replace") + task.recordToolError("edit_file_anthropic") const errorMessage = `File not found: ${relPath}. Cannot perform search and replace on a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) @@ -115,45 +115,45 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { fileContent = fileContent.replace(/\r\n/g, "\n") } catch (error) { task.consecutiveMistakeCount++ - task.recordToolError("search_and_replace") + task.recordToolError("edit_file_anthropic") const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) return } - // Apply all operations sequentially + // Apply all edits sequentially let newContent = fileContent const errors: string[] = [] - for (let i = 0; i < operations.length; i++) { + for (let i = 0; i < edits.length; i++) { // Normalize line endings in search/replace strings to match file content - const search = operations[i].search.replace(/\r\n/g, "\n") - const replace = operations[i].replace.replace(/\r\n/g, "\n") - const searchPattern = new RegExp(escapeRegExp(search), "g") + const old_text = edits[i].old_text.replace(/\r\n/g, "\n") + const new_text = edits[i].new_text.replace(/\r\n/g, "\n") + const searchPattern = new RegExp(escapeRegExp(old_text), "g") const matchCount = newContent.match(searchPattern)?.length ?? 0 if (matchCount === 0) { - errors.push(`Operation ${i + 1}: No match found for search text.`) + errors.push(`Edit ${i + 1}: No match found for old_text.`) continue } if (matchCount > 1) { errors.push( - `Operation ${i + 1}: Found ${matchCount} matches. Please provide more context to make a unique match.`, + `Edit ${i + 1}: Found ${matchCount} matches. Please provide more context to make a unique match.`, ) continue } // Apply the replacement - newContent = newContent.replace(searchPattern, replace) + newContent = newContent.replace(searchPattern, new_text) } - // If all operations failed, return error - if (errors.length === operations.length) { + // If all edits failed, return error + if (errors.length === edits.length) { task.consecutiveMistakeCount++ - task.recordToolError("search_and_replace", "no_match") - pushToolResult(formatResponse.toolError(`All operations failed:\n${errors.join("\n")}`)) + task.recordToolError("edit_file_anthropic", "no_match") + pushToolResult(formatResponse.toolError(`All edits failed:\n${errors.join("\n")}`)) return } @@ -201,7 +201,7 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { // Include any partial errors in the message let resultMessage = "" if (errors.length > 0) { - resultMessage = `Some operations failed:\n${errors.join("\n")}\n\n` + resultMessage = `Some edits failed:\n${errors.join("\n")}\n\n` } const completeMessage = JSON.stringify({ @@ -245,6 +245,8 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } task.didEditFile = true + // Tool usage metrics are recorded by presentAssistantMessage(), which also derives provider variants. + // Recording here would double-count successful runs and skew metrics. // Get the formatted response message const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) @@ -256,32 +258,27 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { pushToolResult(message) } - // Record successful tool usage and cleanup - task.recordToolUsage("search_and_replace") await task.diffViewProvider.reset() // Process any queued messages after file edit completes task.processQueuedMessages() } catch (error) { - await handleError("search and replace", error as Error) + await handleError("edit_file_anthropic", error as Error) await task.diffViewProvider.reset() } } - override async handlePartial(task: Task, block: ToolUse<"search_and_replace">): Promise { + override async handlePartial(task: Task, block: ToolUse<"edit_file_anthropic">): Promise { const relPath: string | undefined = block.params.path - const operationsStr: string | undefined = block.params.operations - - let operationsPreview: string | undefined - if (operationsStr) { - try { - const ops = JSON.parse(operationsStr) - if (Array.isArray(ops) && ops.length > 0) { - operationsPreview = `${ops.length} operation(s)` - } - } catch { - operationsPreview = "parsing..." - } + // For native protocol, nativeArgs contains the edits array + // For XML protocol, edits would be in params (but this tool is native-only) + const nativeArgs = block.nativeArgs as + | { path: string; edits: Array<{ old_text: string; new_text: string }> } + | undefined + + let editsPreview: string | undefined + if (nativeArgs?.edits && Array.isArray(nativeArgs.edits)) { + editsPreview = `${nativeArgs.edits.length} edit(s)` } const absolutePath = relPath ? path.resolve(task.cwd, relPath) : "" @@ -290,7 +287,7 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", path: getReadablePath(task.cwd, relPath || ""), - diff: operationsPreview, + diff: editsPreview, isOutsideWorkspace, } @@ -308,3 +305,5 @@ function escapeRegExp(input: string): string { } export const searchAndReplaceTool = new SearchAndReplaceTool() +// Alias for new naming convention +export const editFileAnthropicTool = searchAndReplaceTool diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/EditFileCodexTool.ts similarity index 94% rename from src/core/tools/ApplyPatchTool.ts rename to src/core/tools/EditFileCodexTool.ts index 000bc14729e..3602714b387 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/EditFileCodexTool.ts @@ -20,8 +20,8 @@ interface ApplyPatchParams { patch: string } -export class ApplyPatchTool extends BaseTool<"apply_patch"> { - readonly name = "apply_patch" as const +export class ApplyPatchTool extends BaseTool<"edit_file_codex"> { + readonly name = "edit_file_codex" as const parseLegacy(params: Partial>): ApplyPatchParams { return { @@ -37,8 +37,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Validate required parameters if (!patch) { task.consecutiveMistakeCount++ - task.recordToolError("apply_patch") - pushToolResult(await task.sayAndCreateMissingParamError("apply_patch", "patch")) + task.recordToolError("edit_file_codex") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file_codex", "patch")) return } @@ -48,7 +48,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { parsedPatch = parsePatch(patch) } catch (error) { task.consecutiveMistakeCount++ - task.recordToolError("apply_patch") + task.recordToolError("edit_file_codex") const errorMessage = error instanceof ParseError ? `Invalid patch format: ${error.message}` @@ -73,7 +73,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { changes = await processAllHunks(parsedPatch.hunks, readFile) } catch (error) { task.consecutiveMistakeCount++ - task.recordToolError("apply_patch") + task.recordToolError("edit_file_codex") const errorMessage = `Failed to process patch: ${error instanceof Error ? error.message : String(error)}` pushToolResult(formatResponse.toolError(errorMessage)) return @@ -108,7 +108,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } task.consecutiveMistakeCount = 0 - task.recordToolUsage("apply_patch") } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() @@ -129,7 +128,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const fileExists = await fileExistsAtPath(absolutePath) if (fileExists) { task.consecutiveMistakeCount++ - task.recordToolError("apply_patch") + task.recordToolError("edit_file_codex") const errorMessage = `File already exists: ${relPath}. Use Update File instead.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) @@ -192,7 +191,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, true, diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } @@ -220,7 +219,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { task.consecutiveMistakeCount++ - task.recordToolError("apply_patch") + task.recordToolError("edit_file_codex") const errorMessage = `File not found: ${relPath}. Cannot delete a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) @@ -278,7 +277,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { task.consecutiveMistakeCount++ - task.recordToolError("apply_patch") + task.recordToolError("edit_file_codex") const errorMessage = `File not found: ${relPath}. Cannot update a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) @@ -363,7 +362,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const isMovePathWriteProtected = task.rooProtectedController?.isWriteProtected(change.movePath) || false if (isMovePathWriteProtected) { task.consecutiveMistakeCount++ - task.recordToolError("apply_patch") + task.recordToolError("edit_file_codex") const errorMessage = `Cannot move file to write-protected path: ${change.movePath}` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) @@ -375,7 +374,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const isMoveOutsideWorkspace = isPathOutsideWorkspace(moveAbsolutePath) if (isMoveOutsideWorkspace) { task.consecutiveMistakeCount++ - task.recordToolError("apply_patch") + task.recordToolError("edit_file_codex") const errorMessage = `Cannot move file to path outside workspace: ${change.movePath}` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) @@ -426,7 +425,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task.processQueuedMessages() } - override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { + override async handlePartial(task: Task, block: ToolUse<"edit_file_codex">): Promise { const patch: string | undefined = block.params.patch let patchPreview: string | undefined @@ -448,3 +447,5 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } export const applyPatchTool = new ApplyPatchTool() +// Alias for new naming convention +export const editFileCodexTool = applyPatchTool diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileGeminiTool.ts similarity index 94% rename from src/core/tools/EditFileTool.ts rename to src/core/tools/EditFileGeminiTool.ts index 8d04fe23016..6532cd9d866 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileGeminiTool.ts @@ -90,8 +90,8 @@ function applyReplacement( return safeLiteralReplace(currentContent, oldString, newString) } -export class EditFileTool extends BaseTool<"edit_file"> { - readonly name = "edit_file" as const +export class EditFileTool extends BaseTool<"edit_file_gemini"> { + readonly name = "edit_file_gemini" as const parseLegacy(params: Partial>): EditFileParams { return { @@ -112,8 +112,8 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Validate required parameters if (!file_path) { task.consecutiveMistakeCount++ - task.recordToolError("edit_file") - pushToolResult(await task.sayAndCreateMissingParamError("edit_file", "file_path")) + task.recordToolError("edit_file_gemini") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file_gemini", "file_path")) return } @@ -150,7 +150,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { currentContent = currentContent.replace(/\r\n/g, "\n") } catch (error) { task.consecutiveMistakeCount++ - task.recordToolError("edit_file") + task.recordToolError("edit_file_gemini") const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) @@ -160,7 +160,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Check if trying to create a file that already exists if (old_string === "") { task.consecutiveMistakeCount++ - task.recordToolError("edit_file") + task.recordToolError("edit_file_gemini") const errorMessage = `File '${relPath}' already exists. Cannot create a new file with empty old_string when file exists.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) @@ -174,7 +174,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { } else { // Trying to replace in non-existent file task.consecutiveMistakeCount++ - task.recordToolError("edit_file") + task.recordToolError("edit_file_gemini") const errorMessage = `File not found: ${relPath}. Cannot perform replacement on a non-existent file. Use an empty old_string to create a new file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) @@ -189,7 +189,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { if (occurrences === 0) { task.consecutiveMistakeCount++ - task.recordToolError("edit_file", "no_match") + task.recordToolError("edit_file_gemini", "no_match") pushToolResult( formatResponse.toolError( `No match found for the specified 'old_string'. Please ensure it matches the file contents exactly, including all whitespace and indentation.`, @@ -201,7 +201,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { if (occurrences !== expected_replacements) { task.consecutiveMistakeCount++ - task.recordToolError("edit_file", "occurrence_mismatch") + task.recordToolError("edit_file_gemini", "occurrence_mismatch") pushToolResult( formatResponse.toolError( `Expected ${expected_replacements} occurrence(s) but found ${occurrences}. Please adjust your old_string to match exactly ${expected_replacements} occurrence(s), or set expected_replacements to ${occurrences}.`, @@ -214,7 +214,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Validate that old_string and new_string are different if (old_string === new_string) { task.consecutiveMistakeCount++ - task.recordToolError("edit_file") + task.recordToolError("edit_file_gemini") pushToolResult( formatResponse.toolError( "No changes to apply. The old_string and new_string are identical.", @@ -324,19 +324,17 @@ export class EditFileTool extends BaseTool<"edit_file"> { pushToolResult(message + replacementInfo) - // Record successful tool usage and cleanup - task.recordToolUsage("edit_file") await task.diffViewProvider.reset() // Process any queued messages after file edit completes task.processQueuedMessages() } catch (error) { - await handleError("edit_file", error as Error) + await handleError("edit_file_gemini", error as Error) await task.diffViewProvider.reset() } } - override async handlePartial(task: Task, block: ToolUse<"edit_file">): Promise { + override async handlePartial(task: Task, block: ToolUse<"edit_file_gemini">): Promise { const filePath: string | undefined = block.params.file_path const oldString: string | undefined = block.params.old_string @@ -371,3 +369,5 @@ export class EditFileTool extends BaseTool<"edit_file"> { } export const editFileTool = new EditFileTool() +// Alias for new naming convention +export const editFileGeminiTool = editFileTool diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/EditFileGrokTool.ts similarity index 90% rename from src/core/tools/SearchReplaceTool.ts rename to src/core/tools/EditFileGrokTool.ts index dadb97fde5a..8e76160e17b 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/EditFileGrokTool.ts @@ -20,8 +20,8 @@ interface SearchReplaceParams { new_string: string } -export class SearchReplaceTool extends BaseTool<"search_replace"> { - readonly name = "search_replace" as const +export class SearchReplaceTool extends BaseTool<"edit_file_grok"> { + readonly name = "edit_file_grok" as const parseLegacy(params: Partial>): SearchReplaceParams { return { @@ -39,29 +39,29 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // Validate required parameters if (!file_path) { task.consecutiveMistakeCount++ - task.recordToolError("search_replace") - pushToolResult(await task.sayAndCreateMissingParamError("search_replace", "file_path")) + task.recordToolError("edit_file_grok") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file_grok", "file_path")) return } if (!old_string) { task.consecutiveMistakeCount++ - task.recordToolError("search_replace") - pushToolResult(await task.sayAndCreateMissingParamError("search_replace", "old_string")) + task.recordToolError("edit_file_grok") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file_grok", "old_string")) return } if (new_string === undefined) { task.consecutiveMistakeCount++ - task.recordToolError("search_replace") - pushToolResult(await task.sayAndCreateMissingParamError("search_replace", "new_string")) + task.recordToolError("edit_file_grok") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file_grok", "new_string")) return } // Validate that old_string and new_string are different if (old_string === new_string) { task.consecutiveMistakeCount++ - task.recordToolError("search_replace") + task.recordToolError("edit_file_grok") pushToolResult( formatResponse.toolError( "The 'old_string' and 'new_string' parameters must be different.", @@ -95,7 +95,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { task.consecutiveMistakeCount++ - task.recordToolError("search_replace") + task.recordToolError("edit_file_grok") const errorMessage = `File not found: ${relPath}. Cannot perform search and replace on a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) @@ -109,7 +109,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { fileContent = fileContent.replace(/\r\n/g, "\n") } catch (error) { task.consecutiveMistakeCount++ - task.recordToolError("search_replace") + task.recordToolError("edit_file_grok") const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage, toolProtocol)) @@ -125,7 +125,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { if (matchCount === 0) { task.consecutiveMistakeCount++ - task.recordToolError("search_replace", "no_match") + task.recordToolError("edit_file_grok", "no_match") pushToolResult( formatResponse.toolError( `No match found for the specified 'old_string'. Please ensure it matches the file contents exactly, including whitespace and indentation.`, @@ -137,7 +137,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { if (matchCount > 1) { task.consecutiveMistakeCount++ - task.recordToolError("search_replace", "multiple_matches") + task.recordToolError("edit_file_grok", "multiple_matches") pushToolResult( formatResponse.toolError( `Found ${matchCount} matches for the specified 'old_string'. This tool can only replace ONE occurrence at a time. Please provide more context (3-5 lines before and after) to uniquely identify the specific instance you want to change.`, @@ -237,8 +237,6 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) pushToolResult(message) - // Record successful tool usage and cleanup - task.recordToolUsage("search_replace") await task.diffViewProvider.reset() // Process any queued messages after file edit completes @@ -249,7 +247,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { } } - override async handlePartial(task: Task, block: ToolUse<"search_replace">): Promise { + override async handlePartial(task: Task, block: ToolUse<"edit_file_grok">): Promise { const filePath: string | undefined = block.params.file_path const oldString: string | undefined = block.params.old_string @@ -281,3 +279,5 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { } export const searchReplaceTool = new SearchReplaceTool() +// Alias for new naming convention +export const editFileGrokTool = searchReplaceTool diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/EditFileRooTool.ts similarity index 89% rename from src/core/tools/ApplyDiffTool.ts rename to src/core/tools/EditFileRooTool.ts index 7161c7c08ef..dbc9372df4f 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/EditFileRooTool.ts @@ -16,22 +16,22 @@ import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" -interface ApplyDiffParams { +interface EditFileRooParams { path: string diff: string } -export class ApplyDiffTool extends BaseTool<"apply_diff"> { - readonly name = "apply_diff" as const +export class EditFileRooTool extends BaseTool<"edit_file_roo"> { + readonly name = "edit_file_roo" as const - parseLegacy(params: Partial>): ApplyDiffParams { + parseLegacy(params: Partial>): EditFileRooParams { return { path: params.path || "", diff: params.diff || "", } } - async execute(params: ApplyDiffParams, task: Task, callbacks: ToolCallbacks): Promise { + async execute(params: EditFileRooParams, task: Task, callbacks: ToolCallbacks): Promise { const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks let { path: relPath, diff: diffContent } = params @@ -42,15 +42,15 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { try { if (!relPath) { task.consecutiveMistakeCount++ - task.recordToolError("apply_diff") - pushToolResult(await task.sayAndCreateMissingParamError("apply_diff", "path")) + task.recordToolError("edit_file_roo") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file_roo", "path")) return } if (!diffContent) { task.consecutiveMistakeCount++ - task.recordToolError("apply_diff") - pushToolResult(await task.sayAndCreateMissingParamError("apply_diff", "diff")) + task.recordToolError("edit_file_roo") + pushToolResult(await task.sayAndCreateMissingParamError("edit_file_roo", "diff")) return } @@ -67,7 +67,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { if (!fileExists) { task.consecutiveMistakeCount++ - task.recordToolError("apply_diff") + task.recordToolError("edit_file_roo") const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` await task.say("error", formattedError) task.didToolFailInCurrentTurn = true @@ -118,7 +118,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { await task.say("diff_error", formattedError) } - task.recordToolError("apply_diff", formattedError) + task.recordToolError("edit_file_roo", formattedError) pushToolResult(formattedError) return @@ -164,9 +164,9 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { let toolProgressStatus if (task.diffStrategy && task.diffStrategy.getProgressStatus) { - const block: ToolUse<"apply_diff"> = { + const block: ToolUse<"edit_file_roo"> = { type: "tool_use", - name: "apply_diff", + name: "edit_file_roo", params: { path: relPath, diff: diffContent }, partial: false, } @@ -208,9 +208,9 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { let toolProgressStatus if (task.diffStrategy && task.diffStrategy.getProgressStatus) { - const block: ToolUse<"apply_diff"> = { + const block: ToolUse<"edit_file_roo"> = { type: "tool_use", - name: "apply_diff", + name: "edit_file_roo", params: { path: relPath, diff: diffContent }, partial: false, } @@ -272,7 +272,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } } - override async handlePartial(task: Task, block: ToolUse<"apply_diff">): Promise { + override async handlePartial(task: Task, block: ToolUse<"edit_file_roo">): Promise { const relPath: string | undefined = block.params.path const diffContent: string | undefined = block.params.diff @@ -296,4 +296,6 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } } -export const applyDiffTool = new ApplyDiffTool() +export const editFileRooTool = new EditFileRooTool() +// Legacy alias for backward compatibility +export const applyDiffTool = editFileRooTool diff --git a/src/core/tools/MultiApplyDiffTool.ts b/src/core/tools/MultiApplyDiffTool.ts index 94cdb3fd492..695c2ca9a01 100644 --- a/src/core/tools/MultiApplyDiffTool.ts +++ b/src/core/tools/MultiApplyDiffTool.ts @@ -14,7 +14,7 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { parseXmlForDiff } from "../../utils/xml" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" -import { applyDiffTool as applyDiffToolClass } from "./ApplyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "./EditFileRooTool" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { isNativeProtocol } from "@roo-code/types" import { resolveToolProtocol } from "../../utils/resolveToolProtocol" @@ -65,7 +65,7 @@ export async function applyDiffTool( // Use the task's locked protocol for consistency throughout the task lifetime const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info, cline.taskToolProtocol) if (isNativeProtocol(toolProtocol)) { - return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + return applyDiffToolClass.handle(cline, block as ToolUse<"edit_file_roo">, { askApproval, handleError, pushToolResult, @@ -85,7 +85,7 @@ export async function applyDiffTool( // If experiment is disabled, use single-file class-based tool if (!isMultiFileApplyDiffEnabled) { - return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + return applyDiffToolClass.handle(cline, block as ToolUse<"edit_file_roo">, { askApproval, handleError, pushToolResult, diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/EditFileGrokTool.spec.ts similarity index 96% rename from src/core/tools/__tests__/searchReplaceTool.spec.ts rename to src/core/tools/__tests__/EditFileGrokTool.spec.ts index 984808e9715..282adf4411d 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/EditFileGrokTool.spec.ts @@ -7,7 +7,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" import { ToolUse, ToolResponse } from "../../../shared/tools" -import { searchReplaceTool } from "../SearchReplaceTool" +import { searchReplaceTool } from "../EditFileGrokTool" vi.mock("fs/promises", () => ({ default: { @@ -179,7 +179,7 @@ describe("searchReplaceTool", () => { const toolUse: ToolUse = { type: "tool_use", - name: "search_replace", + name: "edit_file_grok", params: { file_path: testFilePath, old_string: testOldString, @@ -193,7 +193,7 @@ describe("searchReplaceTool", () => { toolResult = result }) - await searchReplaceTool.handle(mockCline, toolUse as ToolUse<"search_replace">, { + await searchReplaceTool.handle(mockCline, toolUse as ToolUse<"edit_file_grok">, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -210,7 +210,7 @@ describe("searchReplaceTool", () => { expect(result).toBe("Missing param error") expect(mockCline.consecutiveMistakeCount).toBe(1) - expect(mockCline.recordToolError).toHaveBeenCalledWith("search_replace") + expect(mockCline.recordToolError).toHaveBeenCalledWith("edit_file_grok") }) it("returns error when old_string is missing", async () => { @@ -268,7 +268,7 @@ describe("searchReplaceTool", () => { expect(result).toContain("Error:") expect(result).toContain("No match found") expect(mockCline.consecutiveMistakeCount).toBe(1) - expect(mockCline.recordToolError).toHaveBeenCalledWith("search_replace", "no_match") + expect(mockCline.recordToolError).toHaveBeenCalledWith("edit_file_grok", "no_match") }) it("returns error when multiple matches are found", async () => { @@ -280,7 +280,7 @@ describe("searchReplaceTool", () => { expect(result).toContain("Error:") expect(result).toContain("3 matches") expect(mockCline.consecutiveMistakeCount).toBe(1) - expect(mockCline.recordToolError).toHaveBeenCalledWith("search_replace", "multiple_matches") + expect(mockCline.recordToolError).toHaveBeenCalledWith("edit_file_grok", "multiple_matches") }) it("successfully replaces single unique match", async () => { @@ -306,7 +306,7 @@ describe("searchReplaceTool", () => { expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockCline.didEditFile).toBe(true) - expect(mockCline.recordToolUsage).toHaveBeenCalledWith("search_replace") + expect(mockCline.recordToolUsage).not.toHaveBeenCalled() }) it("reverts changes when user rejects", async () => { @@ -335,7 +335,7 @@ describe("searchReplaceTool", () => { const toolUse: ToolUse = { type: "tool_use", - name: "search_replace", + name: "edit_file_grok", params: { file_path: testFilePath, old_string: testOldString, @@ -349,7 +349,7 @@ describe("searchReplaceTool", () => { capturedResult = result }) - await searchReplaceTool.handle(mockCline, toolUse as ToolUse<"search_replace">, { + await searchReplaceTool.handle(mockCline, toolUse as ToolUse<"edit_file_grok">, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: localPushToolResult, diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index 65d7cb67749..0e368c19a99 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -8,8 +8,8 @@ vi.mock("vscode", () => ({ }, })) -// Mock the ApplyDiffTool module -vi.mock("../ApplyDiffTool", () => ({ +// Mock the EditFileRooTool module +vi.mock("../EditFileRooTool", () => ({ applyDiffTool: { handle: vi.fn(), }, @@ -17,7 +17,7 @@ vi.mock("../ApplyDiffTool", () => ({ // Import after mocking to get the mocked version import { applyDiffTool as multiApplyDiffTool } from "../MultiApplyDiffTool" -import { applyDiffTool as applyDiffToolClass } from "../ApplyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "../EditFileRooTool" describe("applyDiffTool experiment routing", () => { let mockCline: any diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index ab632252dff..b925f7139a4 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -7,7 +7,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" import { ToolUse, ToolResponse } from "../../../shared/tools" -import { editFileTool } from "../EditFileTool" +import { editFileTool } from "../EditFileGeminiTool" vi.mock("fs/promises", () => ({ default: { @@ -179,7 +179,7 @@ describe("editFileTool", () => { const toolUse: ToolUse = { type: "tool_use", - name: "edit_file", + name: "edit_file_gemini", params: { file_path: testFilePath, old_string: testOldString, @@ -193,7 +193,7 @@ describe("editFileTool", () => { toolResult = result }) - await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, { + await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file_gemini">, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -210,7 +210,7 @@ describe("editFileTool", () => { expect(result).toBe("Missing param error") expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file") + expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file_gemini") }) it("treats undefined new_string as empty string (deletion)", async () => { @@ -268,7 +268,7 @@ describe("editFileTool", () => { expect(result).toContain("Error:") expect(result).toContain("No match found") expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file", "no_match") + expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file_gemini", "no_match") }) it("returns error when occurrence count does not match expected_replacements", async () => { @@ -280,7 +280,7 @@ describe("editFileTool", () => { expect(result).toContain("Error:") expect(result).toContain("Expected 1 occurrence(s) but found 3") expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file", "occurrence_mismatch") + expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file_gemini", "occurrence_mismatch") }) it("succeeds when occurrence count matches expected_replacements", async () => { @@ -348,7 +348,7 @@ describe("editFileTool", () => { expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) - expect(mockTask.recordToolUsage).toHaveBeenCalledWith("edit_file") + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() }) it("reverts changes when user rejects", async () => { @@ -380,9 +380,9 @@ describe("editFileTool", () => { it("handles file read errors gracefully", async () => { mockedFsReadFile.mockRejectedValueOnce(new Error("Read failed")) - const toolUse: ToolUse = { + const toolUse: ToolUse<"edit_file_gemini"> = { type: "tool_use", - name: "edit_file", + name: "edit_file_gemini", params: { file_path: testFilePath, old_string: testOldString, @@ -396,7 +396,7 @@ describe("editFileTool", () => { capturedResult = result }) - await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, { + await editFileTool.handle(mockTask, toolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: localPushToolResult, @@ -414,7 +414,7 @@ describe("editFileTool", () => { await executeEditFileTool() - expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) + expect(mockHandleError).toHaveBeenCalledWith("edit_file_gemini", expect.any(Error)) expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() }) }) diff --git a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts index 0910550dd82..06e0359ff3a 100644 --- a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts +++ b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts @@ -1,5 +1,5 @@ import { applyDiffTool } from "../MultiApplyDiffTool" -import { applyDiffTool as applyDiffToolClass } from "../ApplyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "../EditFileRooTool" import { EXPERIMENT_IDS } from "../../../shared/experiments" import * as fs from "fs/promises" import * as fileUtils from "../../../utils/fs" @@ -11,8 +11,8 @@ vi.mock("../../../utils/fs") vi.mock("../../../utils/path") vi.mock("../../../utils/xml") -// Mock the ApplyDiffTool class-based tool that MultiApplyDiffTool delegates to for native protocol -vi.mock("../ApplyDiffTool", () => ({ +// Mock the EditFileRooTool class-based tool that MultiApplyDiffTool delegates to for native protocol +vi.mock("../EditFileRooTool", () => ({ applyDiffTool: { handle: vi.fn().mockResolvedValue(undefined), }, diff --git a/src/core/tools/__tests__/searchAndReplaceTool.spec.ts b/src/core/tools/__tests__/searchAndReplaceTool.spec.ts index c73744ec57c..a8b933276c7 100644 --- a/src/core/tools/__tests__/searchAndReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchAndReplaceTool.spec.ts @@ -7,7 +7,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" import { ToolUse, ToolResponse } from "../../../shared/tools" -import { searchAndReplaceTool } from "../SearchAndReplaceTool" +import { searchAndReplaceTool } from "../EditFileAnthropicTool" vi.mock("fs/promises", () => ({ default: { @@ -177,10 +177,10 @@ describe("searchAndReplaceTool", () => { const toolUse: ToolUse = { type: "tool_use", - name: "search_and_replace", + name: "edit_file_anthropic", params: { path: testFilePath, - operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]), + edits: JSON.stringify([{ old_text: "Line 2", new_text: "Modified Line 2" }]), ...params, }, partial: isPartial, @@ -190,7 +190,7 @@ describe("searchAndReplaceTool", () => { toolResult = result }) - await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"search_and_replace">, { + await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"edit_file_anthropic">, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -207,22 +207,22 @@ describe("searchAndReplaceTool", () => { expect(result).toBe("Missing param error") expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("search_and_replace") + expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file_anthropic") }) - it("returns error when operations is missing", async () => { - const result = await executeSearchAndReplaceTool({ operations: undefined }) + it("returns error when edits is missing", async () => { + const result = await executeSearchAndReplaceTool({ edits: undefined }) expect(result).toContain("Error:") - expect(result).toContain("Missing or empty 'operations' parameter") + expect(result).toContain("Missing or empty 'edits' parameter") expect(mockTask.consecutiveMistakeCount).toBe(1) }) - it("returns error when operations is empty array", async () => { - const result = await executeSearchAndReplaceTool({ operations: JSON.stringify([]) }) + it("returns error when edits is empty array", async () => { + const result = await executeSearchAndReplaceTool({ edits: JSON.stringify([]) }) expect(result).toContain("Error:") - expect(result).toContain("Missing or empty 'operations' parameter") + expect(result).toContain("Missing or empty 'edits' parameter") expect(mockTask.consecutiveMistakeCount).toBe(1) }) }) @@ -246,19 +246,19 @@ describe("searchAndReplaceTool", () => { describe("search and replace logic", () => { it("returns error when no match is found", async () => { const result = await executeSearchAndReplaceTool( - { operations: JSON.stringify([{ search: "NonExistent", replace: "New" }]) }, + { edits: JSON.stringify([{ old_text: "NonExistent", new_text: "New" }]) }, { fileContent: "Line 1\nLine 2\nLine 3" }, ) expect(result).toContain("Error:") expect(result).toContain("No match found") expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("search_and_replace", "no_match") + expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file_anthropic", "no_match") }) it("returns error when multiple matches are found", async () => { const result = await executeSearchAndReplaceTool( - { operations: JSON.stringify([{ search: "Line", replace: "Row" }]) }, + { edits: JSON.stringify([{ old_text: "Line", new_text: "Row" }]) }, { fileContent: "Line 1\nLine 2\nLine 3" }, ) @@ -269,7 +269,7 @@ describe("searchAndReplaceTool", () => { it("successfully replaces single unique match", async () => { await executeSearchAndReplaceTool( - { operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]) }, + { edits: JSON.stringify([{ old_text: "Line 2", new_text: "Modified Line 2" }]) }, { fileContent: "Line 1\nLine 2\nLine 3" }, ) @@ -284,7 +284,7 @@ describe("searchAndReplaceTool", () => { const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" await executeSearchAndReplaceTool( - { operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]) }, + { edits: JSON.stringify([{ old_text: "Line 2", new_text: "Modified Line 2" }]) }, { fileContent: contentWithCRLF }, ) @@ -299,7 +299,7 @@ describe("searchAndReplaceTool", () => { const searchWithCRLF = "Line 1\r\nLine 2" await executeSearchAndReplaceTool( - { operations: JSON.stringify([{ search: searchWithCRLF, replace: "Modified Lines" }]) }, + { edits: JSON.stringify([{ old_text: searchWithCRLF, new_text: "Modified Lines" }]) }, { fileContent: contentWithCRLF }, ) @@ -314,7 +314,7 @@ describe("searchAndReplaceTool", () => { const searchWithLF = "Line 1\nLine 2" await executeSearchAndReplaceTool( - { operations: JSON.stringify([{ search: searchWithLF, replace: "Modified Lines" }]) }, + { edits: JSON.stringify([{ old_text: searchWithLF, new_text: "Modified Lines" }]) }, { fileContent: contentWithCRLF }, ) @@ -331,7 +331,7 @@ describe("searchAndReplaceTool", () => { expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) - expect(mockTask.recordToolUsage).toHaveBeenCalledWith("search_and_replace") + // Tool usage metrics are recorded by presentAssistantMessage(), not in the tool itself }) it("reverts changes when user rejects", async () => { @@ -359,10 +359,10 @@ describe("searchAndReplaceTool", () => { const toolUse: ToolUse = { type: "tool_use", - name: "search_and_replace", + name: "edit_file_anthropic", params: { path: testFilePath, - operations: JSON.stringify([{ search: "Line 2", replace: "Modified" }]), + edits: JSON.stringify([{ old_text: "Line 2", new_text: "Modified" }]), }, partial: false, } @@ -372,7 +372,7 @@ describe("searchAndReplaceTool", () => { capturedResult = result }) - await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"search_and_replace">, { + await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"edit_file_anthropic">, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: localPushToolResult, @@ -390,7 +390,7 @@ describe("searchAndReplaceTool", () => { await executeSearchAndReplaceTool() - expect(mockHandleError).toHaveBeenCalledWith("search and replace", expect.any(Error)) + expect(mockHandleError).toHaveBeenCalledWith("edit_file_anthropic", expect.any(Error)) expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() }) }) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 87aa1594208..7ec18863202 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -93,13 +93,15 @@ describe("mode-validator", () => { groups: ["edit"] as const, }, ] - const requirements = { apply_diff: false } + // Use write_to_file for requirement testing (edit_file is the unified tool name + // which is handled separately from TOOL_GROUPS in filterNativeToolsForMode) + const requirements = { write_to_file: false } // Should respect disabled requirement even if tool group is allowed - expect(isToolAllowedForMode("apply_diff", "custom-mode", customModes, requirements)).toBe(false) + expect(isToolAllowedForMode("write_to_file", "custom-mode", customModes, requirements)).toBe(false) - // Should allow other edit tools - expect(isToolAllowedForMode("write_to_file", "custom-mode", customModes, requirements)).toBe(true) + // Should allow other edit tools when not disabled + expect(isToolAllowedForMode("generate_image", "custom-mode", customModes, requirements)).toBe(true) }) }) @@ -140,28 +142,35 @@ describe("mode-validator", () => { }) describe("tool requirements", () => { + // Note: apply_diff is now a legacy tool name. The unified edit tool is "edit_file". + // For testing requirements, we use write_to_file which is still in TOOL_GROUPS. it("respects tool requirements when provided", () => { - const requirements = { apply_diff: false } - expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) + const requirements = { write_to_file: false } + expect(isToolAllowedForMode("write_to_file", codeMode, [], requirements)).toBe(false) - const enabledRequirements = { apply_diff: true } - expect(isToolAllowedForMode("apply_diff", codeMode, [], enabledRequirements)).toBe(true) + const enabledRequirements = { write_to_file: true } + expect(isToolAllowedForMode("write_to_file", codeMode, [], enabledRequirements)).toBe(true) }) it("allows tools when their requirements are not specified", () => { const requirements = { some_other_tool: true } - expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(true) + expect(isToolAllowedForMode("write_to_file", codeMode, [], requirements)).toBe(true) }) it("handles undefined and empty requirements", () => { - expect(isToolAllowedForMode("apply_diff", codeMode, [], undefined)).toBe(true) - expect(isToolAllowedForMode("apply_diff", codeMode, [], {})).toBe(true) + expect(isToolAllowedForMode("write_to_file", codeMode, [], undefined)).toBe(true) + expect(isToolAllowedForMode("write_to_file", codeMode, [], {})).toBe(true) }) it("prioritizes requirements over mode configuration", () => { - const requirements = { apply_diff: false } + const requirements = { write_to_file: false } // Even in code mode which allows all tools, disabled requirement should take precedence - expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("write_to_file", codeMode, [], requirements)).toBe(false) + }) + + it("can gate native unified edit tool (edit_file)", () => { + const requirements = { edit_file: false } + expect(isToolAllowedForMode("edit_file", codeMode, [], requirements)).toBe(false) }) }) }) @@ -186,19 +195,19 @@ describe("mode-validator", () => { }) it("throws error when tool requirement is not met", () => { - const requirements = { apply_diff: false } - expect(() => validateToolUse("apply_diff", codeMode, [], requirements)).toThrow( - 'Tool "apply_diff" is not allowed in code mode.', + const requirements = { write_to_file: false } + expect(() => validateToolUse("write_to_file", codeMode, [], requirements)).toThrow( + 'Tool "write_to_file" is not allowed in code mode.', ) }) it("does not throw when tool requirement is met", () => { - const requirements = { apply_diff: true } - expect(() => validateToolUse("apply_diff", codeMode, [], requirements)).not.toThrow() + const requirements = { write_to_file: true } + expect(() => validateToolUse("write_to_file", codeMode, [], requirements)).not.toThrow() }) it("handles undefined requirements gracefully", () => { - expect(() => validateToolUse("apply_diff", codeMode, [], undefined)).not.toThrow() + expect(() => validateToolUse("write_to_file", codeMode, [], undefined)).not.toThrow() }) }) }) diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 751d164fd26..9b2055fdb97 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -62,7 +62,18 @@ export function validateToolUse( } } -const EDIT_OPERATION_PARAMS = ["diff", "content", "operations", "search", "replace", "args", "line"] as const +const EDIT_OPERATION_PARAMS = [ + "diff", + "content", + "edits", + "old_text", + "new_text", + "args", + "line", + "old_string", // edit_file_grok/gemini variant parameter + "new_string", // edit_file_grok/gemini variant parameter + "patch", // edit_file_codex variant parameter +] as const function getGroupOptions(group: GroupEntry): GroupOptions | undefined { return Array.isArray(group) ? group[1] : undefined @@ -155,7 +166,8 @@ export function isToolAllowedForMode( // For the edit group, check file regex if specified if (groupName === "edit" && options.fileRegex) { - const filePath = toolParams?.path + // Support both 'path' (roo/anthropic variants) and 'file_path' (grok/gemini variants) + const filePath = toolParams?.path || toolParams?.file_path // Check if this is an actual edit operation (not just path-only for streaming) const isEditOperation = EDIT_OPERATION_PARAMS.some((param) => toolParams?.[param]) @@ -164,6 +176,36 @@ export function isToolAllowedForMode( throw new FileRestrictionError(mode.name, options.fileRegex, options.description, filePath, tool) } + // Handle codex patch parameter which contains embedded file paths + if (toolParams?.patch && typeof toolParams.patch === "string") { + try { + // Extract file paths from codex patch format: + // *** Add File: path, *** Update File: path, *** Delete File: path, *** Move to: path + const patchPathRegex = + /\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*([^\n]+)|\*\*\*\s+Move\s+to:\s*([^\n]+)/g + let match + while ((match = patchPathRegex.exec(toolParams.patch)) !== null) { + const extractedPath = (match[1] || match[2])?.trim() + if (extractedPath && !doesFileMatchRegex(extractedPath, options.fileRegex)) { + throw new FileRestrictionError( + mode.name, + options.fileRegex, + options.description, + extractedPath, + tool, + ) + } + } + } catch (error) { + // Re-throw FileRestrictionError as it's an expected validation error + if (error instanceof FileRestrictionError) { + throw error + } + // If patch parsing fails, log the error but don't block the operation + console.warn(`Failed to parse codex patch for file restriction validation: ${error}`) + } + } + // Handle XML args parameter (used by MULTI_FILE_APPLY_DIFF experiment) if (toolParams?.args && typeof toolParams.args === "string") { // Extract file paths from XML args with improved validation diff --git a/src/shared/__tests__/modes.spec.ts b/src/shared/__tests__/modes.spec.ts index a00abde7879..7593e44565d 100644 --- a/src/shared/__tests__/modes.spec.ts +++ b/src/shared/__tests__/modes.spec.ts @@ -326,7 +326,7 @@ describe("isToolAllowedForMode", () => { it("disallows customTools by default (not in includedTools)", () => { // search_and_replace is a customTool in the edit group, should be disallowed by default - expect(isToolAllowedForMode("search_and_replace", "test-custom-tools", customModesWithEditGroup)).toBe( + expect(isToolAllowedForMode("edit_file_anthropic", "test-custom-tools", customModesWithEditGroup)).toBe( false, ) }) @@ -335,13 +335,13 @@ describe("isToolAllowedForMode", () => { // search_and_replace should be allowed when explicitly included expect( isToolAllowedForMode( - "search_and_replace", + "edit_file_anthropic", "test-custom-tools", customModesWithEditGroup, undefined, undefined, undefined, - ["search_and_replace"], + ["edit_file_anthropic"], ), ).toBe(true) }) @@ -359,13 +359,13 @@ describe("isToolAllowedForMode", () => { // Even if included, should be disallowed because the mode doesn't have edit group expect( isToolAllowedForMode( - "search_and_replace", + "edit_file_anthropic", "no-edit-mode", customModesWithoutEdit, undefined, undefined, undefined, - ["search_and_replace"], + ["edit_file_anthropic"], ), ).toBe(false) }) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f2b4ec3544e..09cfa698b5d 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -70,12 +70,14 @@ export const toolParamNames = [ "prompt", "image", "files", // Native protocol parameter for read_file - "operations", // search_and_replace parameter for multiple operations - "patch", // apply_patch parameter - "file_path", // search_replace and edit_file parameter - "old_string", // search_replace and edit_file parameter - "new_string", // search_replace and edit_file parameter - "expected_replacements", // edit_file parameter for multiple occurrences + "edits", // edit_file (anthropic variant) parameter for multiple edit operations + "old_text", // edit_file (anthropic variant) parameter for text to replace + "new_text", // edit_file (anthropic variant) parameter for replacement text + "patch", // edit_file (codex variant) parameter + "file_path", // edit_file (grok/gemini variant) parameter + "old_string", // edit_file (grok/gemini variant) parameter + "new_string", // edit_file (grok/gemini variant) parameter + "expected_replacements", // edit_file (gemini variant) parameter for multiple occurrences ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -91,11 +93,27 @@ export type NativeToolArgs = { read_file: { files: FileEntry[] } attempt_completion: { result: string } execute_command: { command: string; cwd?: string } + // apply_diff is kept for XML protocol backward compatibility apply_diff: { path: string; diff: string } - search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> } - search_replace: { file_path: string; old_string: string; new_string: string } - edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } - apply_patch: { patch: string } + // Unified edit_file for native protocol - variant-specific args based on modelInfo.editToolVariant + // The actual arg structure depends on which variant is selected: + // - roo: { path, diff } + // - anthropic: { path, edits } + // - grok: { file_path, old_string, new_string } + // - gemini: { file_path, old_string, new_string, expected_replacements? } + // - codex: { patch } + edit_file: + | { path: string; diff: string } + | { path: string; edits: Array<{ old_text: string; new_text: string }> } + | { file_path: string; old_string: string; new_string: string } + | { file_path: string; old_string: string; new_string: string; expected_replacements?: number } + | { patch: string } + // Internal edit tool variant names (used by native protocol, presented to LLM as "edit_file") + edit_file_roo: { path: string; diff: string } + edit_file_anthropic: { path: string; edits: Array<{ old_text: string; new_text: string }> } + edit_file_grok: { file_path: string; old_string: string; new_string: string } + edit_file_gemini: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } + edit_file_codex: { patch: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -248,11 +266,17 @@ export const TOOL_DISPLAY_NAMES: Record = { read_file: "read files", fetch_instructions: "fetch instructions", write_to_file: "write files", + // apply_diff is kept for XML protocol backward compatibility apply_diff: "apply changes", - search_and_replace: "apply changes using search and replace", - search_replace: "apply single search and replace", - edit_file: "edit files using search and replace", - apply_patch: "apply patches using codex format", + // Unified edit tool for native protocol + edit_file: "edit files", + // Internal edit tool variant names (used by native protocol) + edit_file_roo: "edit files (roo format)", + edit_file_anthropic: "edit files (anthropic format)", + edit_file_grok: "edit files (grok format)", + edit_file_gemini: "edit files (gemini format)", + edit_file_codex: "edit files (codex format)", + // Other tools search_files: "search files", list_files: "list files", browser_action: "use a browser", @@ -274,8 +298,12 @@ export const TOOL_GROUPS: Record = { tools: ["read_file", "fetch_instructions", "search_files", "list_files", "codebase_search"], }, edit: { - tools: ["apply_diff", "write_to_file", "generate_image"], - customTools: ["search_and_replace", "search_replace", "edit_file", "apply_patch"], + // For XML protocol: apply_diff is included for backward compatibility + // For native protocol: filterNativeToolsForMode selects the appropriate edit_file_* variant + // and presents it as "edit_file" to the LLM + tools: ["write_to_file", "apply_diff", "edit_file", "generate_image"], + // Variant names for native protocol internal use - one is selected based on modelInfo.editToolVariant + customTools: ["edit_file_roo", "edit_file_anthropic", "edit_file_grok", "edit_file_gemini", "edit_file_codex"], }, browser: { tools: ["browser_action"], @@ -306,14 +334,18 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ * Central registry of tool aliases. * Maps alias name -> canonical tool name. * - * This allows models to use alternative names for tools (e.g., "edit_file" instead of "apply_diff"). + * This allows models to use alternative names for tools. * When a model calls a tool by its alias, the system resolves it to the canonical name for execution, * but preserves the alias in API conversation history for consistency. * - * To add a new alias, simply add an entry here. No other files need to be modified. + * Note: Legacy edit tool aliases (apply_diff, search_and_replace, search_replace) + * have been removed. Native protocol uses editToolVariant to select the edit tool schema. + * The codex variant can optionally be presented to the LLM as "apply_patch". + * XML protocol still uses apply_diff directly. */ export const TOOL_ALIASES: Record = { write_file: "write_to_file", + apply_patch: "edit_file", } as const export type DiffResult =