diff --git a/proto/cline/models.proto b/proto/cline/models.proto index b8903823ef1..80b72956eb3 100644 --- a/proto/cline/models.proto +++ b/proto/cline/models.proto @@ -269,6 +269,8 @@ message ModelsApiConfiguration { optional string qwen_code_oauth_path = 70; optional string dify_api_key = 71; optional string dify_base_url = 72; + optional string morph_api_key = 73; + optional string morph_api_url = 74; // Plan mode configurations optional ApiProvider plan_mode_api_provider = 100; diff --git a/proto/cline/state.proto b/proto/cline/state.proto index b0a07109971..7f35ecbeda5 100644 --- a/proto/cline/state.proto +++ b/proto/cline/state.proto @@ -196,6 +196,8 @@ message ApiConfiguration { optional string qwen_code_oauth_path = 63; optional string dify_api_key = 64; optional string dify_base_url = 65; + optional string morph_api_key = 66; + optional string morph_api_url = 67; // Plan mode configurations optional string plan_mode_api_provider = 100; diff --git a/src/config.ts b/src/config.ts index 9daf820b92d..072390ed482 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,9 @@ export enum Environment { local = "local", } +export const DEFAULT_MORPH_API_URL = "https://api.morphllm.com/v1" +export const DEFAULT_MORPH_MODEL = "morph-v3-fast" + interface EnvironmentConfig { appBaseUrl: string apiBaseUrl: string diff --git a/src/core/assistant-message/index.ts b/src/core/assistant-message/index.ts index 4adefdb393c..4ce07888c89 100644 --- a/src/core/assistant-message/index.ts +++ b/src/core/assistant-message/index.ts @@ -13,6 +13,7 @@ export const toolUseNames = [ "read_file", "write_to_file", "replace_in_file", + "edit_file", "search_files", "list_files", "list_code_definition_names", @@ -40,6 +41,9 @@ export const toolParamNames = [ "path", "content", "diff", + "target_file", + "instructions", + "code_edit", "regex", "file_pattern", "recursive", diff --git a/src/core/assistant-message/parse-assistant-message.ts b/src/core/assistant-message/parse-assistant-message.ts index 955bb6807db..6106f1e6ace 100644 --- a/src/core/assistant-message/parse-assistant-message.ts +++ b/src/core/assistant-message/parse-assistant-message.ts @@ -102,26 +102,38 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess currentCharIndex - toolCloseTag.length + 1, // To before the tool closing tag ) - // Check if content parameter needs special handling (write_to_file/new_rule) - // This check is important if the closing tag was missed by the parameter parsing logic + // Special handling for multi-line content parameters (write_to_file/new_rule/edit_file) + // This check ensures correct parsing even if the closing tag was missed by the standard parameter parsing logic // (e.g., if content is empty or parsing logic prioritizes tool close) - const contentParamName: ToolParamName = "content" - if ( - currentToolUse.name === "write_to_file" /* || currentToolUse.name === "new_rule" */ && - toolContentSlice.includes(`<${contentParamName}>`) - ) { - const contentStartTag = `<${contentParamName}>` - const contentEndTag = `` - const contentStart = toolContentSlice.indexOf(contentStartTag) - // Use lastIndexOf for robustness against nested tags - const contentEnd = toolContentSlice.lastIndexOf(contentEndTag) - - if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart) { - const contentValue = toolContentSlice.slice(contentStart + contentStartTag.length, contentEnd).trim() - currentToolUse.params[contentParamName] = contentValue + + const handleSpecialContentParam = (paramName: ToolParamName) => { + if (toolContentSlice.includes(`<${paramName}>`)) { + const startTag = `<${paramName}>` + const endTag = `` + const start = toolContentSlice.indexOf(startTag) + // Use lastIndexOf for robustness against potentially nested tags within the content + const end = toolContentSlice.lastIndexOf(endTag) + + if (start !== -1 && end !== -1 && end > start) { + // Only update if the parameter hasn't already been captured by the primary logic + if (currentToolUse!.params[paramName] === undefined) { + const value = toolContentSlice.slice(start + startTag.length, end).trim() + currentToolUse!.params[paramName] = value + } + } } } + if (currentToolUse.name === "write_to_file" /* || currentToolUse.name === "new_rule" */) { + handleSpecialContentParam("content") + } else if (currentToolUse.name === "edit_file") { + handleSpecialContentParam("code_edit") + // 'instructions' and 'target_file' are typically handled by the standard logic as they are single-line, + // but we include them here as a safeguard against malformed XML. + handleSpecialContentParam("instructions") + handleSpecialContentParam("target_file") + } + currentToolUse.partial = false // Mark as complete contentBlocks.push(currentToolUse) currentToolUse = undefined // Reset state diff --git a/src/core/controller/index.ts b/src/core/controller/index.ts index b720d444aaa..b3fb6cf8f13 100644 --- a/src/core/controller/index.ts +++ b/src/core/controller/index.ts @@ -685,6 +685,8 @@ export class Controller { mcpResponsesCollapsed, terminalOutputLineLimit, customPrompt, + // expose replace_in_file toggle to webview + enableSearchReplaceTool: this.stateManager.getGlobalStateKey("enableSearchReplaceTool"), } } diff --git a/src/core/controller/state/updateSettings.ts b/src/core/controller/state/updateSettings.ts index 3ee3b77589e..578654db121 100644 --- a/src/core/controller/state/updateSettings.ts +++ b/src/core/controller/state/updateSettings.ts @@ -47,6 +47,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett controller.stateManager.setGlobalState("enableCheckpointsSetting", request.enableCheckpointsSetting) } + // New: Update enableSearchReplaceTool setting + if ((request as any).enableSearchReplaceTool !== undefined) { + controller.stateManager.setGlobalState( + "enableSearchReplaceTool" as any, + Boolean((request as any).enableSearchReplaceTool) as unknown as never, + ) + } + // Update MCP marketplace setting if (request.mcpMarketplaceEnabled !== undefined) { controller.stateManager.setGlobalState("mcpMarketplaceEnabled", request.mcpMarketplaceEnabled) diff --git a/src/core/prompts/system-prompt/components/editing_files.ts b/src/core/prompts/system-prompt/components/editing_files.ts index dce97939ef3..e533311e73a 100644 --- a/src/core/prompts/system-prompt/components/editing_files.ts +++ b/src/core/prompts/system-prompt/components/editing_files.ts @@ -4,57 +4,84 @@ import type { PromptVariant, SystemPromptContext } from "../types" const EDITING_FILES_TEMPLATE_TEXT = `EDITING FILES -You have access to two tools for working with files: **write_to_file** and **replace_in_file**. Understanding their roles and selecting the right one for the job will help ensure efficient and accurate modifications. +You have access to three tools for working with files: **edit_file**, **replace_in_file**, and **write_to_file**. Your default tool for any modification to an existing file must be **edit_file**. The other tools should only be used as fallbacks in specific scenarios. -# write_to_file +**Decision Hierarchy: Always prefer \`edit_file\` > \`replace_in_file\` > \`write_to_file\` for existing files.** -## Purpose +# edit_file (Primary Tool) -- Create a new file, or overwrite the entire contents of an existing file. +## Purpose +Make semantic, targeted edits to existing files by specifying only the changed lines and using placeholders for unchanged code. This is the safest and most efficient method. ## When to Use - -- Initial file creation, such as when scaffolding a new project. -- Overwriting large boilerplate files where you want to replace the entire content at once. -- When the complexity or number of changes would make replace_in_file unwieldy or error-prone. -- When you need to completely restructure a file's content or change its fundamental organization. +- **This is your default tool for all edits to existing files.** +- Use for single or multiple changes within a file, from simple line edits to complex structural refactoring. ## Important Considerations +- Use placeholders to represent unchanged sections (e.g., \`// ... existing code ...\`). Provide minimal but sufficient surrounding context (1-3 lines) before and after your changes to ensure accuracy. +- Combine all required changes for a single file into one \`edit_file\` call. The tool is designed to handle multiple distinct edits at once. -- Using write_to_file requires providing the file's complete final content. -- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file. -- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it. +# replace_in_file (Fallback Tool) -# replace_in_file +## Purpose & When to Use +Use this tool **only as a fallback** to \`edit_file\` if it has failed. It performs a simple search and replace. -## Purpose +# write_to_file (Use with Caution) + +## Purpose & When to Use +- **Creating new files.** +- **Complete rewrites:** Only use this to overwrite an existing file when the changes are so extensive that both \`edit_file\` and \`replace_in_file\` are impractical or impossible. + +# Choosing the Appropriate Tool -- Make targeted edits to specific parts of an existing file without overwriting the entire file. +- **Always default to \`edit_file\` for any modification.** It is the most robust and preferred method. + +# Auto-formatting Considerations + +- After using edit_file, the user's editor may automatically format the file +- This auto-formatting may modify the file contents, for example: + - Breaking single lines into multiple lines + - Adjusting indentation to match project style (e.g. 2 spaces vs 4 spaces vs tabs) + - Converting single quotes to double quotes (or vice versa based on project preferences) + - Organizing imports (e.g. sorting, grouping by type) + - Adding/removing trailing commas in objects and arrays + - Enforcing consistent brace style (e.g. same-line vs new-line) + - Standardizing semicolon usage (adding or removing based on style) +- The edit_file tool response will include the final state of the file after any auto-formatting +- Use this final state as your reference point for any subsequent edits.` + +const EDITING_FILES_NO_REPLACE_TEXT = `EDITING FILES + +You have access to two tools for working with files: **edit_file** and **write_to_file**. The **replace_in_file** (Search/Replace) tool is disabled by default and not available unless explicitly enabled in settings. + +**Decision Hierarchy: Always prefer \`edit_file\` > \`write_to_file\` for existing files.** + +# edit_file (Primary Tool) + +## Purpose +Make semantic, targeted edits to existing files by specifying only the changed lines and using placeholders for unchanged code. This is the safest and most efficient method. ## When to Use +- **This is your default tool for all edits to existing files.** +- Use for single or multiple changes within a file, from simple line edits to complex structural refactoring. -- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc. -- Targeted improvements where only specific portions of the file's content needs to be altered. -- Especially useful for long files where much of the file will remain unchanged. +## Important Considerations +- Use placeholders to represent unchanged sections (e.g., \`// ... existing code ...\`). Provide minimal but sufficient surrounding context (1-3 lines) before and after your changes to ensure accuracy. +- Combine all required changes for a single file into one \`edit_file\` call. The tool is designed to handle multiple distinct edits at once. -## Advantages +# write_to_file (Use with Caution) -- More efficient for minor edits, since you don't need to supply the entire file content. -- Reduces the chance of errors that can occur when overwriting large files. +## Purpose & When to Use +- **Creating new files.** +- **Complete rewrites:** Only use this to overwrite an existing file when the changes are so extensive that \`edit_file\` is impractical. # Choosing the Appropriate Tool -- **Default to replace_in_file** for most changes. It's the safer, more precise option that minimizes potential issues. -- **Use write_to_file** when: - - Creating new files - - The changes are so extensive that using replace_in_file would be more complex or risky - - You need to completely reorganize or restructure a file - - The file is relatively small and the changes affect most of its content - - You're generating boilerplate or template files +- **Always default to \`edit_file\` for any modification.** It is the most robust and preferred method. # Auto-formatting Considerations -- After using either write_to_file or replace_in_file, the user's editor may automatically format the file +- After using edit_file, the user's editor may automatically format the file - This auto-formatting may modify the file contents, for example: - Breaking single lines into multiple lines - Adjusting indentation to match project style (e.g. 2 spaces vs 4 spaces vs tabs) @@ -63,19 +90,13 @@ You have access to two tools for working with files: **write_to_file** and **rep - Adding/removing trailing commas in objects and arrays - Enforcing consistent brace style (e.g. same-line vs new-line) - Standardizing semicolon usage (adding or removing based on style) -- The write_to_file and replace_in_file tool responses will include the final state of the file after any auto-formatting -- Use this final state as your reference point for any subsequent edits. This is ESPECIALLY important when crafting SEARCH blocks for replace_in_file which require the content to match what's in the file exactly. - -# Workflow Tips - -1. Before editing, assess the scope of your changes and decide which tool to use. -2. For targeted edits, apply replace_in_file with carefully crafted SEARCH/REPLACE blocks. If you need multiple changes, you can stack multiple SEARCH/REPLACE blocks within a single replace_in_file call. -3. For major overhauls or initial file creation, rely on write_to_file. -4. Once the file has been edited with either write_to_file or replace_in_file, the system will provide you with the final state of the modified file. Use this updated content as the reference point for any subsequent SEARCH/REPLACE operations, since it reflects any auto-formatting or user-applied changes. -By thoughtfully selecting between write_to_file and replace_in_file, you can make your file editing process smoother, safer, and more efficient.` +- The edit_file tool response will include the final state of the file after any auto-formatting +- Use this final state as your reference point for any subsequent edits.` -export async function getEditingFilesSection(variant: PromptVariant, _context: SystemPromptContext): Promise { - const template = variant.componentOverrides?.[SystemPromptSection.EDITING_FILES]?.template || EDITING_FILES_TEMPLATE_TEXT +export async function getEditingFilesSection(variant: PromptVariant, context: SystemPromptContext): Promise { + const template = + variant.componentOverrides?.[SystemPromptSection.EDITING_FILES]?.template || + (context.enableSearchReplaceTool ? EDITING_FILES_TEMPLATE_TEXT : EDITING_FILES_NO_REPLACE_TEXT) return new TemplateEngine().resolve(template, {}) } diff --git a/src/core/prompts/system-prompt/components/tool_use/examples.ts b/src/core/prompts/system-prompt/components/tool_use/examples.ts index 29d7a6d4135..8dd7badc0fc 100644 --- a/src/core/prompts/system-prompt/components/tool_use/examples.ts +++ b/src/core/prompts/system-prompt/components/tool_use/examples.ts @@ -27,6 +27,8 @@ const FOCUS_CHAIN_EXAMPLE_EDIT = ` const TOOL_USE_EXAMPLES_TEMPLATE_TEXT = `# Tool Use Examples +To make changes to code, always use the edit_file tool. + ## Example 1: Requesting to execute a command @@ -87,43 +89,33 @@ const TOOL_USE_EXAMPLES_TEMPLATE_TEXT = `# Tool Use Examples -## Example 4: Requesting to make targeted edits to a file +## Example 4: Requesting to make edits to a file - -src/components/App.tsx - -------- SEARCH -import React from 'react'; -======= + +src/components/App.tsx +I will add a loading state and update submit logic + import React, { useState } from 'react'; -+++++++ REPLACE -------- SEARCH -function handleSubmit() { - saveData(); - setLoading(false); -} +function App() { + const [loading, setLoading] = useState(false); -======= -+++++++ REPLACE + async function handleSubmit() { + setLoading(true); + // ... existing code ... + setLoading(false); + } -------- SEARCH -return ( -
-======= -function handleSubmit() { - saveData(); - setLoading(false); + return ( +
+ {/* ... existing code ... */} +
+ ); } + +{{FOCUS_CHAIN_EXAMPLE_EDIT}} -return ( -
-+++++++ REPLACE - -{{FOCUS_CHAIN_EXAMPLE_EDIT}} - - -## Example 5: Requesting to use an MCP tool +## Example 6: Requesting to use an MCP tool weather-server @@ -136,7 +128,7 @@ return ( -## Example 6: Another example of using an MCP tool (where the server name is a unique identifier such as a URL) +## Example 7: Another example of using an MCP tool (where the server name is a unique identifier such as a URL) github.com/modelcontextprotocol/servers/tree/main/src/github diff --git a/src/core/prompts/system-prompt/tools/edit_file.ts b/src/core/prompts/system-prompt/tools/edit_file.ts new file mode 100644 index 00000000000..bfb7bed7543 --- /dev/null +++ b/src/core/prompts/system-prompt/tools/edit_file.ts @@ -0,0 +1,74 @@ +import { ModelFamily } from "@/shared/prompts" +import type { ClineToolSpec } from "../spec" + +// This description is derived directly from the MORPH_APPLY_INTEGRATION.md guidelines. +const EDIT_FILE_DESCRIPTION = `Use this tool to make an edit to an existing file. This is the preferred way to modify files. + +This will be read by a specialized, fast model (Morph Apply Model), which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write. +When writing the edit, you should specify each edit in sequence, with the special comment \`// ... existing code ...\` (or the equivalent comment syntax for the file's language) to represent unchanged code in between edited lines. + +For example: + +// ... existing code ... +FIRST_EDIT +// ... existing code ... +SECOND_EDIT +// ... existing code ... + +You should still bias towards repeating as few lines of the original file as possible to convey the change. +But, each edit should contain minimally sufficient context (1-3 lines) of unchanged lines around the code you're editing to resolve ambiguity. +DO NOT omit spans of pre-existing code (or comments) without using the \`// ... existing code ...\` comment to indicate its absence. If you omit the existing code comment, the apply model may inadvertently delete these lines. + +# Deleting Code +If you plan on deleting a section, you must provide context before and after the section you want to remove. +Example: If the initial code is: +\`\`\` +function keepThis() { return "stay"; } +function removeThis() { return "go"; } +function alsoKeepThis() { return "also stay"; } +\`\`\` +And you want to remove \`removeThis\`, your output should be: +\`\`\` +// ... existing code ... +function keepThis() { return "stay"; } + +function alsoKeepThis() { return "also stay"; } +// ... existing code ... +\`\`\` + +# Multiple Edits +Make all edits to a file in a single \`edit_file\` call instead of multiple calls to the same file. The apply model can handle many distinct edits at once.` + +export const edit_file: ClineToolSpec = { + // Casting to any because src/shared/tools.ts is not available in context yet. + id: "edit_file" as any, + variant: ModelFamily.GENERIC, + name: "edit_file", + description: EDIT_FILE_DESCRIPTION, + parameters: [ + { + name: "target_file", + required: true, + description: "The path to the file that needs to be modified.", + instruction: "Specify the relative path to the file you want to edit.", + }, + { + name: "instructions", + required: true, + description: + "A single sentence instruction describing what you are going to do for the sketched edit. This is used to assist the apply model. Use the first person to describe what you are going to do. Use it to disambiguate uncertainty in the edit.", + instruction: + 'Provide a concise, first-person description of the change (e.g., "I am adding async error handling to the login function.").', + }, + { + name: "code_edit", + required: true, + description: + "The abbreviated code snippet representing the change. Must use `// ... existing code ...` (or equivalent comment syntax) to represent unchanged sections.", + instruction: + "Provide the code snippet with your changes, using `// ... existing code ...` for unchanged sections. Include minimal context around your edits.", + }, + ], +} + +export const edit_file_variants = [edit_file] diff --git a/src/core/prompts/system-prompt/tools/index.ts b/src/core/prompts/system-prompt/tools/index.ts index 00fa2f3fe59..f0295189b1d 100644 --- a/src/core/prompts/system-prompt/tools/index.ts +++ b/src/core/prompts/system-prompt/tools/index.ts @@ -2,6 +2,7 @@ export * from "./access_mcp_resource" export * from "./ask_followup_question" export * from "./attempt_completion" export * from "./browser_action" +export * from "./edit_file" export * from "./execute_command" export * from "./focus_chain" export * from "./init" diff --git a/src/core/prompts/system-prompt/tools/init.ts b/src/core/prompts/system-prompt/tools/init.ts index aa3fe89ab33..b018089453d 100644 --- a/src/core/prompts/system-prompt/tools/init.ts +++ b/src/core/prompts/system-prompt/tools/init.ts @@ -4,6 +4,7 @@ import { access_mcp_resource_variants } from "./access_mcp_resource" import { ask_followup_question_variants } from "./ask_followup_question" import { attempt_completion_variants } from "./attempt_completion" import { browser_action_variants } from "./browser_action" +import { edit_file_variants } from "./edit_file" import { execute_command_variants } from "./execute_command" import { focus_chain_variants } from "./focus_chain" import { list_code_definition_names_variants } from "./list_code_definition_names" @@ -30,6 +31,7 @@ export function registerClineToolSets(): void { ...ask_followup_question_variants, ...attempt_completion_variants, ...browser_action_variants, + ...edit_file_variants, ...execute_command_variants, ...focus_chain_variants, ...list_code_definition_names_variants, diff --git a/src/core/prompts/system-prompt/tools/replace_in_file.ts b/src/core/prompts/system-prompt/tools/replace_in_file.ts index 396ad3499fe..de9f0785fa6 100644 --- a/src/core/prompts/system-prompt/tools/replace_in_file.ts +++ b/src/core/prompts/system-prompt/tools/replace_in_file.ts @@ -11,6 +11,8 @@ const generic: ClineToolSpec = { name: "replace_in_file", description: "Request to replace sections of content in an existing file using SEARCH/REPLACE blocks that define exact changes to specific parts of the file. This tool should be used when you need to make targeted changes to specific parts of a file.", + // Only enable when explicitly allowed by settings + contextRequirements: (context) => Boolean(context.enableSearchReplaceTool), parameters: [ { name: "path", diff --git a/src/core/prompts/system-prompt/types.ts b/src/core/prompts/system-prompt/types.ts index 9c3218e07a4..68f9483e25c 100644 --- a/src/core/prompts/system-prompt/types.ts +++ b/src/core/prompts/system-prompt/types.ts @@ -104,6 +104,8 @@ export interface SystemPromptContext { readonly browserSettings?: BrowserSettings readonly isTesting?: boolean readonly runtimePlaceholders?: Readonly> + // New: gate for exposing replace_in_file tool and prompt variant + readonly enableSearchReplaceTool?: boolean } /** diff --git a/src/core/storage/StateManager.ts b/src/core/storage/StateManager.ts index abb63521753..3842fbe38dd 100644 --- a/src/core/storage/StateManager.ts +++ b/src/core/storage/StateManager.ts @@ -253,6 +253,8 @@ export class StateManager { huaweiCloudMaasApiKey, difyApiKey, difyBaseUrl, + morphApiKey, + morphApiUrl, vercelAiGatewayApiKey, zaiApiKey, requestTimeoutMs, @@ -423,6 +425,7 @@ export class StateManager { sapAiCoreUseOrchestrationMode, claudeCodePath, difyBaseUrl, + morphApiUrl, qwenCodeOauthPath, }) @@ -460,6 +463,7 @@ export class StateManager { huggingFaceApiKey, huaweiCloudMaasApiKey, difyApiKey, + morphApiKey, vercelAiGatewayApiKey, zaiApiKey, }) @@ -666,6 +670,7 @@ export class StateManager { huggingFaceApiKey: this.secretsCache["huggingFaceApiKey"], huaweiCloudMaasApiKey: this.secretsCache["huaweiCloudMaasApiKey"], difyApiKey: this.secretsCache["difyApiKey"], + morphApiKey: this.secretsCache["morphApiKey"], vercelAiGatewayApiKey: this.secretsCache["vercelAiGatewayApiKey"], zaiApiKey: this.secretsCache["zaiApiKey"], @@ -707,6 +712,7 @@ export class StateManager { claudeCodePath: this.globalStateCache["claudeCodePath"], qwenCodeOauthPath: this.globalStateCache["qwenCodeOauthPath"], difyBaseUrl: this.globalStateCache["difyBaseUrl"], + morphApiUrl: this.globalStateCache["morphApiUrl"], // Plan mode configurations planModeApiProvider: this.globalStateCache["planModeApiProvider"], diff --git a/src/core/storage/state-keys.ts b/src/core/storage/state-keys.ts index 4dccd54aff5..3e765f079ff 100644 --- a/src/core/storage/state-keys.ts +++ b/src/core/storage/state-keys.ts @@ -83,6 +83,9 @@ export interface GlobalState { focusChainFeatureFlagEnabled: boolean customPrompt: "compact" | undefined difyBaseUrl: string | undefined + morphApiUrl: string | undefined + // New: gate for replace_in_file tool availability (default false) + enableSearchReplaceTool: boolean // Plan mode configurations planModeApiProvider: ApiProvider @@ -184,6 +187,7 @@ export interface Secrets { basetenApiKey: string | undefined vercelAiGatewayApiKey: string | undefined difyApiKey: string | undefined + morphApiKey: string | undefined } export interface LocalState { diff --git a/src/core/storage/utils/state-helpers.ts b/src/core/storage/utils/state-helpers.ts index ae978c1bc20..132579e979c 100644 --- a/src/core/storage/utils/state-helpers.ts +++ b/src/core/storage/utils/state-helpers.ts @@ -50,6 +50,8 @@ export async function readSecretsFromDisk(context: ExtensionContext): Promise, @@ -86,6 +88,8 @@ export async function readSecretsFromDisk(context: ExtensionContext): Promise, context.secrets.get("vercelAiGatewayApiKey") as Promise, context.secrets.get("difyApiKey") as Promise, + // Added Morph API key retrieval + context.secrets.get("morphApiKey") as Promise, context.secrets.get("authNonce") as Promise, ]) @@ -125,6 +129,8 @@ export async function readSecretsFromDisk(context: ExtensionContext): Promise context.secrets.delete(key))) await controller.stateManager.reInitialize() diff --git a/src/core/task/ToolExecutor.ts b/src/core/task/ToolExecutor.ts index e1452fbccf8..884065d3078 100644 --- a/src/core/task/ToolExecutor.ts +++ b/src/core/task/ToolExecutor.ts @@ -26,6 +26,7 @@ import { AskFollowupQuestionToolHandler } from "./tools/handlers/AskFollowupQues import { AttemptCompletionHandler } from "./tools/handlers/AttemptCompletionHandler" import { BrowserToolHandler } from "./tools/handlers/BrowserToolHandler" import { CondenseHandler } from "./tools/handlers/CondenseHandler" +import { EditFileToolHandler } from "./tools/handlers/EditFileToolHandler" import { ExecuteCommandToolHandler } from "./tools/handlers/ExecuteCommandToolHandler" import { ListCodeDefinitionNamesToolHandler } from "./tools/handlers/ListCodeDefinitionNamesToolHandler" import { ListFilesToolHandler } from "./tools/handlers/ListFilesToolHandler" @@ -190,6 +191,9 @@ export class ToolExecutor { this.coordinator.register(new SharedToolHandler("replace_in_file", writeHandler)) this.coordinator.register(new SharedToolHandler("new_rule", writeHandler)) + // Register EditFileToolHandler + this.coordinator.register(new EditFileToolHandler(validator)) + this.coordinator.register(new ListCodeDefinitionNamesToolHandler(validator)) this.coordinator.register(new SearchFilesToolHandler(validator)) this.coordinator.register(new ExecuteCommandToolHandler(validator)) diff --git a/src/core/task/index.ts b/src/core/task/index.ts index 9955d746c75..450f5bd7f02 100644 --- a/src/core/task/index.ts +++ b/src/core/task/index.ts @@ -1707,6 +1707,8 @@ export class Task { clineIgnoreInstructions, preferredLanguageInstructions, browserSettings: this.browserSettings, + // New: gate replace_in_file tool exposure (default disabled) + enableSearchReplaceTool: this.stateManager.getGlobalStateKey("enableSearchReplaceTool") ?? false, } const systemPrompt = await getSystemPrompt(promptContext) diff --git a/src/core/task/tools/handlers/EditFileToolHandler.ts b/src/core/task/tools/handlers/EditFileToolHandler.ts new file mode 100644 index 00000000000..4ea5ad7c634 --- /dev/null +++ b/src/core/task/tools/handlers/EditFileToolHandler.ts @@ -0,0 +1,111 @@ +import type { ToolUse } from "@core/assistant-message" +import { readFile } from "fs/promises" +import * as path from "path" +import { formatResponse } from "@/core/prompts/responses" +import type { ToolResponse } from "@/core/task" +import { Logger } from "@/services/logging/Logger" +import { MorphApplyService } from "@/services/morph/MorphApplyService" +import type { IFullyManagedTool } from "../ToolExecutorCoordinator" +import { ToolValidator } from "../ToolValidator" +import type { TaskConfig } from "../types/TaskConfig" +import type { StronglyTypedUIHelpers } from "../types/UIHelpers" +import { WriteToFileToolHandler } from "./WriteToFileToolHandler" + +export class EditFileToolHandler implements IFullyManagedTool { + readonly name = "edit_file" + + constructor(private validator: ToolValidator) {} + + getDescription(block: ToolUse): string { + const rel = block.params.target_file + return `[${block.name}${rel ? ` for '${rel}'` : ""}]` + } + + async handlePartialBlock(block: ToolUse, ui: StronglyTypedUIHelpers): Promise { + const relPath = block.params.target_file + const instructions = block.params.instructions + const codeEdit = block.params.code_edit + + // Only show a lightweight status message once we have a path + if (!relPath) return + + const config = ui.getConfig() + const displayPath = path.relative(config.cwd, path.resolve(config.cwd, relPath)) + const status = instructions ? `Instructions: ${instructions}` : "Waiting for instructions and code edit..." + await ui.say( + "tool", + JSON.stringify({ tool: "edit_file", path: displayPath, content: codeEdit || status }), + undefined, + undefined, + block.partial, + ) + } + + async execute(config: TaskConfig, block: ToolUse): Promise { + const target_file = block.params.target_file + const instructions = block.params.instructions + const code_edit = block.params.code_edit + + if (!target_file) return await config.callbacks.sayAndCreateMissingParamError("edit_file", "target_file") + if (!instructions) return await config.callbacks.sayAndCreateMissingParamError("edit_file", "instructions", target_file) + if (!code_edit) return await config.callbacks.sayAndCreateMissingParamError("edit_file", "code_edit", target_file) + + const absPath = path.resolve(config.cwd, target_file) + let initialCode: string + try { + initialCode = await readFile(absPath, "utf-8") + } catch { + Logger.warn(`[EditFileToolHandler] File not found or unreadable: ${absPath}`) + return formatResponse.toolError(`File not found or cannot be read: ${target_file}`) + } + + try { + // Use Morph service with current state manager + Logger.debug( + `[EditFileToolHandler] Invoking Morph fast apply for '${target_file}' (instructionsLen=${instructions.length}, codeEditLen=${code_edit.length}, initialLen=${initialCode.length})`, + ) + const morphService = new MorphApplyService(config.services.stateManager) + const result = await morphService.applyEdit(initialCode, instructions, code_edit) + + if (result.startsWith("Error:")) { + const errorMessage = result.substring(6).trim() + Logger.warn(`[EditFileToolHandler] Morph Apply failed, initiating fallback: ${errorMessage}`) + const enableSearchReplaceTool = config.services.stateManager.getGlobalStateKey( + "enableSearchReplaceTool", + ) as boolean + if (enableSearchReplaceTool) { + await config.callbacks.say( + "error", + `Morph Fast Apply failed for ${target_file}: ${errorMessage}. Instructing LLM to fall back to replace_in_file`, + ) + return formatResponse.toolError( + `Morph Fast Apply failed: ${errorMessage}. You MUST now use replace_in_file as a fallback to apply the required changes based on the original instructions.`, + ) + } + await config.callbacks.say( + "error", + `Morph Fast Apply failed for ${target_file}: ${errorMessage}. The replace_in_file tool is disabled. Consider updating your edit_file code edit or performing a full rewrite with write_to_file if necessary.`, + ) + return formatResponse.toolError(`Morph Fast Apply failed: ${errorMessage}.`) + } + + Logger.debug( + `[EditFileToolHandler] Morph succeeded for '${target_file}', merged content length=${result.length}. Proceeding to write_to_file.`, + ) + // Apply merged code by delegating to write_to_file + const writeHandler = new WriteToFileToolHandler(this.validator) + const writeBlock: ToolUse = { + type: "tool_use", + name: "write_to_file", + params: { path: target_file, content: result }, + partial: false, + } + return await writeHandler.execute(config, writeBlock) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + Logger.error(`[EditFileToolHandler] Unexpected error during execution: ${message}`) + await config.callbacks.say("error", `An unexpected error occurred while applying edit to ${target_file}: ${message}.`) + return formatResponse.toolError(`Failed to apply edit: ${message}`) + } + } +} diff --git a/src/services/morph/MorphApplyService.ts b/src/services/morph/MorphApplyService.ts new file mode 100644 index 00000000000..d2a4ea53b33 --- /dev/null +++ b/src/services/morph/MorphApplyService.ts @@ -0,0 +1,84 @@ +import { errorService } from "@services/error" +import { Logger } from "@services/logging/Logger" +import OpenAI from "openai" +import { DEFAULT_MORPH_API_URL, DEFAULT_MORPH_MODEL } from "@/config" +import { StateManager } from "@/core/storage/StateManager" + +export class MorphApplyService { + private stateManager: StateManager + + constructor(stateManager: StateManager) { + this.stateManager = stateManager + } + + /** + * Applies a code edit using the Morph API. + * + * @param initialCode The full original code of the file. + * @param instructions A single sentence instruction describing the change. + * @param codeEdit The abbreviated code snippet representing the change. + * @returns The fully merged code after applying the edit, or an "Error: ..." string on failure. + */ + public async applyEdit(initialCode: string, instructions: string, codeEdit: string): Promise { + // Use getApiConfiguration for consistency, although direct accessors are also viable here. + const { morphApiKey, morphApiUrl } = this.stateManager.getApiConfiguration() + const apiKey = morphApiKey + const baseUrl = morphApiUrl || DEFAULT_MORPH_API_URL + + // Basic debug to verify Morph path engagement and input sizes + Logger.debug( + `[MorphApplyService] applyEdit invoked (model=${DEFAULT_MORPH_MODEL}, baseUrl=${baseUrl}); sizes: initialCode=${initialCode?.length ?? 0}, instructions=${instructions?.length ?? 0}, codeEdit=${codeEdit?.length ?? 0}`, + ) + + if (!apiKey) { + // We return an error string instead of throwing, so the tool handler can manage the fallback logic. + Logger.warn("[MorphApplyService] Morph API key is not configured.") + return "Error: Morph API key is not configured. Please ask the user to configure it in the settings (Code Editing Utilities)." + } + + const client = new OpenAI({ + apiKey: apiKey, + baseURL: baseUrl, + }) + + const payload = this.constructPayload(initialCode, instructions, codeEdit) + + try { + const startMs = Date.now() + Logger.info(`[MorphApplyService] Calling Morph API at ${baseUrl}`) + const response = await client.chat.completions.create({ + model: DEFAULT_MORPH_MODEL, + messages: [ + { + role: "user", + content: payload, + }, + ], + temperature: 0.2, // Recommended temperature for code generation consistency + }) + + const mergedCode = response.choices[0]?.message?.content + if (mergedCode === null || mergedCode === undefined) { + throw new Error("Morph API returned null or undefined content.") + } + + Logger.debug( + `[MorphApplyService] Morph API succeeded in ${Date.now() - startMs}ms; mergedCode length=${mergedCode.length}`, + ) + return mergedCode + } catch (error) { + const clineError = errorService.toClineError(error, DEFAULT_MORPH_MODEL, "morph") + errorService.logException(clineError) + // Return the error message so the tool handler can proceed with fallback. + return `Error: ${clineError.message}` + } + } + + /** + * Constructs the specific XML payload required by the Morph API. + */ + private constructPayload(initialCode: string, instructions: string, codeEdit: string): string { + // Use standard string concatenation or template literals, ensuring newlines are correctly inserted. + return `${instructions}\n${initialCode}\n${codeEdit}` + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 0562a51da44..ba6ec103747 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -71,6 +71,8 @@ export interface ExtensionState { focusChainSettings: FocusChainSettings focusChainFeatureFlagEnabled?: boolean customPrompt?: string + // New: gate for Search/Replace tool in UI + enableSearchReplaceTool?: boolean } export interface ClineMessage { @@ -189,16 +191,16 @@ export interface ClinePlanModeResponse { selected?: string } +export interface ClineAskNewTask { + context: string +} + export interface ClineAskQuestion { question: string options?: string[] selected?: string } -export interface ClineAskNewTask { - context: string -} - export interface ClineApiReqInfo { request?: string tokensIn?: number diff --git a/src/shared/api.ts b/src/shared/api.ts index ef905e239c9..25a285699a9 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -109,6 +109,9 @@ export interface ApiHandlerOptions { huaweiCloudMaasApiKey?: string difyApiKey?: string difyBaseUrl?: string + // Added Morph config + morphApiKey?: string + morphApiUrl?: string zaiApiKey?: string zaiApiLine?: string onRetryAttempt?: (attempt: number, maxRetries: number, delay: number, error: any) => void diff --git a/src/shared/proto-conversions/models/api-configuration-conversion.ts b/src/shared/proto-conversions/models/api-configuration-conversion.ts index 29261c372d4..58dcc940f6a 100644 --- a/src/shared/proto-conversions/models/api-configuration-conversion.ts +++ b/src/shared/proto-conversions/models/api-configuration-conversion.ts @@ -414,6 +414,9 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA zaiApiKey: config.zaiApiKey, difyApiKey: config.difyApiKey, difyBaseUrl: config.difyBaseUrl, + // Morph configuration + morphApiKey: (config as any).morphApiKey, + morphApiUrl: (config as any).morphApiUrl, // Plan mode configurations planModeApiProvider: config.planModeApiProvider ? convertApiProviderToProto(config.planModeApiProvider) : undefined, @@ -559,6 +562,9 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio zaiApiKey: protoConfig.zaiApiKey, difyApiKey: protoConfig.difyApiKey, difyBaseUrl: protoConfig.difyBaseUrl, + // Morph configuration + morphApiKey: (protoConfig as any).morphApiKey, + morphApiUrl: (protoConfig as any).morphApiUrl, // Plan mode configurations planModeApiProvider: diff --git a/webview-ui/src/components/settings/common/DebouncedTextField.tsx b/webview-ui/src/components/settings/common/DebouncedTextField.tsx index 433939a68ce..1d0e983778a 100644 --- a/webview-ui/src/components/settings/common/DebouncedTextField.tsx +++ b/webview-ui/src/components/settings/common/DebouncedTextField.tsx @@ -16,13 +16,21 @@ interface DebouncedTextFieldProps { id?: string children?: React.ReactNode disabled?: boolean + readOnly?: boolean } /** * A wrapper around VSCodeTextField that automatically handles debounced input * to prevent excessive API calls while typing */ -export const DebouncedTextField = ({ initialValue, onChange, children, type, ...otherProps }: DebouncedTextFieldProps) => { +export const DebouncedTextField = ({ + initialValue, + onChange, + children, + type, + readOnly, + ...otherProps +}: DebouncedTextFieldProps) => { const [localValue, setLocalValue] = useDebouncedInput(initialValue, onChange) return ( @@ -32,6 +40,7 @@ export const DebouncedTextField = ({ initialValue, onChange, children, type, ... const value = e.target.value setLocalValue(type === "url" ? value.trim() : value) }} + readOnly={readOnly as any} type={type} value={localValue}> {children} diff --git a/webview-ui/src/components/settings/providers/MorphProvider.tsx b/webview-ui/src/components/settings/providers/MorphProvider.tsx new file mode 100644 index 00000000000..2dc6bebbc0c --- /dev/null +++ b/webview-ui/src/components/settings/providers/MorphProvider.tsx @@ -0,0 +1,56 @@ +import { Mode } from "@shared/storage/types" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { ApiKeyField } from "../common/ApiKeyField" +import { DebouncedTextField } from "../common/DebouncedTextField" +import { ModelInfoView } from "../common/ModelInfoView" +import { normalizeApiConfiguration } from "../utils/providerUtils" +import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers" + +interface MorphProviderProps { + showModelOptions: boolean + isPopup?: boolean + currentMode: Mode +} + +export const MorphProvider = ({ showModelOptions, isPopup, currentMode }: MorphProviderProps) => { + const { apiConfiguration } = useExtensionState() + const { handleFieldChange } = useApiConfigurationHandlers() + + const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, currentMode) + + return ( +
+
+ { + handleFieldChange("morphApiUrl" as any, value as any) + }} + placeholder={"Enter base URL..."} + style={{ width: "100%", marginBottom: 10 }} + type="url"> + Base URL + + + { + handleFieldChange("morphApiKey" as any, value as any) + }} + providerName="Morph" + /> + +
+

+ Morph Fast Apply uses a code-aware merge model to apply your instructions to existing files. Configure + your Morph base URL and API key to enable the edit_file tool. +

+
+
+ + {showModelOptions && ( + + )} +
+ ) +} diff --git a/webview-ui/src/components/settings/sections/ApiConfigurationSection.tsx b/webview-ui/src/components/settings/sections/ApiConfigurationSection.tsx index 8ed89864ef7..aee95d8c543 100644 --- a/webview-ui/src/components/settings/sections/ApiConfigurationSection.tsx +++ b/webview-ui/src/components/settings/sections/ApiConfigurationSection.tsx @@ -6,6 +6,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext" import { StateServiceClient } from "@/services/grpc-client" import { TabButton } from "../../mcp/configuration/McpConfigurationView" import ApiOptions from "../ApiOptions" +import { ApiKeyField } from "../common/ApiKeyField" import Section from "../Section" import { syncModeConfigurations } from "../utils/providerUtils" import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers" @@ -15,7 +16,7 @@ interface ApiConfigurationSectionProps { } const ApiConfigurationSection = ({ renderSectionHeader }: ApiConfigurationSectionProps) => { - const { planActSeparateModelsSetting, mode, apiConfiguration } = useExtensionState() + const { planActSeparateModelsSetting, mode, apiConfiguration, enableSearchReplaceTool } = useExtensionState() const [currentTab, setCurrentTab] = useState(mode) const { handleFieldsChange } = useApiConfigurationHandlers() return ( @@ -57,6 +58,40 @@ const ApiConfigurationSection = ({ renderSectionHeader }: ApiConfigurationSectio )} + {/* Code Editing Utilities */} +
+ +
+ { + handleFieldsChange({ morphApiKey: value } as any) + }} + providerName="Morph" + /> + +

+ Used by the edit_file tool to apply code edits quickly. Enter your Morph API key. +

+ + { + const checked = e.target.checked === true + try { + await StateServiceClient.updateSettings({ enableSearchReplaceTool: checked } as any) + } catch (error) { + console.error("Failed to update enableSearchReplaceTool setting:", error) + } + }}> + Enable Search Replace tool (replace_in_file) + +
+
+