Unverified 提交 1e92840b authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Turbo edit file tool (#2094)

<!-- CURSOR_SUMMARY --> > [!NOTE] > Implements targeted file editing via an external API and updates the local agent/tooling to use it by default. > > - Adds `edit_file` tool (`src/pro/main/ipc/handlers/local_agent/tools/edit_file.ts`) calling `POST /tools/turbo-file-edit` with original and edit snippets; writes returned content; optionally deploys Supabase functions; default consent "always" > - Registers `edit_file` in `TOOL_DEFINITIONS`; disables `search_replace` in `tool_definitions.ts` > - Simplifies error handling in tool execution wrapper to output only the message (no stack) > - Requires Dyad Pro API key from `providerSettings.auto.apiKey`; `DYAD_ENGINE_URL` env var overrides default > - Updates e2e fixtures and snapshots to use `edit_file` and reflect "Turbo Edit" flow; adds fake server endpoint `POST /engine/v1/tools/turbo-file-edit` returning a canned result > - Snapshot changes show edited content placeholder (`TURBO EDITED filePath`) replacing previous search/replace output > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit eec1753aa4805a5633a31f4457ee882b04cafd3b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a new edit_file tool that uses the Dyad Turbo File Edit API to apply targeted edits to existing files and write the result. Also disables the search_replace tool and simplifies error output to only show the message. - **Dependencies** - Requires Dyad Pro API key in settings (providerSettings.auto.apiKey). - DYAD_ENGINE_URL env var can override the default https://engine.dyad.sh/v1. <sup>Written for commit eec1753aa4805a5633a31f4457ee882b04cafd3b. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 13d3a9e6
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Read a file, then edit it with search/replace",
description: "Read a file, then edit it with edit_file",
turns: [
{
text: "Let me first read the current file contents to understand what we're working with.",
......@@ -18,11 +18,12 @@ export const fixture: LocalAgentFixture = {
text: "Now I'll update the welcome message to say Hello World instead.",
toolCalls: [
{
name: "search_replace",
name: "edit_file",
args: {
path: "src/App.tsx",
search: "const App = () => <div>Minimal imported app</div>;",
replace: "const App = () => <div>UPDATED imported app</div>;",
content: `// ... existing code ...
const App = () => <div>UPDATED imported app</div>;
// ... existing code ...`,
description: "Update welcome message",
},
},
......
=== src/App.tsx ===
const App = () => <div>UPDATED imported app</div>;
export default App;
TURBO EDITED filePath
\ No newline at end of file
......@@ -52,6 +52,36 @@
}
}
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "\n## When to Use edit_file\n\nUse the `edit_file` tool ONLY when you are editing an existing file. The edit output will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.\n\n**Use only ONE edit_file call per file.** If you need to make multiple changes to the same file, include all edits in sequence within a single call using `// ... existing code ...` comments between them.\n\n## When NOT to Use edit_file\n\nDo NOT use this tool when:\n- You are creating a brand-new file (use file creation tools instead).\n- You are rewriting most of an existing file (in those cases, output the complete file instead).\n\n## Basic Format\n\nWhen writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.\n\nBasic example:\n```\nedit_file(path=\"file.js\", description=\"change code\", content=\"\"\"\n// ... existing code ...\nFIRST_EDIT\n// ... existing code ...\nSECOND_EDIT\n// ... existing code ...\nTHIRD_EDIT\n// ... existing code ...\n\"\"\")\n```\n\n## General Principles\n\nYou should bias towards repeating as few lines of the original file as possible to convey the change.\n\nNEVER show unmodified code in the edit, unless sufficient context of unchanged lines around the code you're editing is needed to resolve ambiguity.\n\nDO NOT omit spans of pre-existing code without using the // ... existing code ... comment to indicate its absence.\n\n## Example: Basic Edit\n```\nedit_file(path=\"LandingPage.tsx\", description=\"Update title.\", content=\"\"\"\n// ... existing code ...\n\nconst LandingPage = () => {\n // ... existing code ...\n return (\n <div>hello</div>\n );\n};\n\n// ... existing code ...\n\"\"\")\n```\n\n## Example: Deleting Code\n\n**When deleting code, you must provide surrounding context and leave an explicit comment indicating what was removed.**\n```\nedit_file(path=\"utils.ts\", description=\"Remove deprecated helper function\", content=\"\"\"\n// ... existing code ...\n\nexport function currentHelper() {\n return \"active\";\n}\n\n// REMOVED: deprecatedHelper() function\n\nexport function anotherHelper() {\n return \"working\";\n}\n\n// ... existing code ...\n\"\"\")\n```\n",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path relative to the app root"
},
"content": {
"type": "string",
"description": "The updated code snippet to apply"
},
"description": {
"type": "string",
"description": "Brief description of the edit"
}
},
"required": [
"path",
"content"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
......@@ -123,41 +153,6 @@
}
}
},
{
"type": "function",
"function": {
"name": "search_replace",
"description": "Apply targeted search/replace edits to a file. This is the preferred tool for editing a file.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to edit"
},
"search": {
"type": "string",
"description": "Content to search for in the file. This should match the existing code that will be replaced"
},
"replace": {
"type": "string",
"description": "New content to replace the search content with"
},
"description": {
"type": "string",
"description": "Brief description of the changes"
}
},
"required": [
"path",
"search",
"replace"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
......
......@@ -9,6 +9,8 @@
- button:
- img
- img
- text: auto
- img
- text: less than a minute ago
- button "Request ID":
- img
......@@ -18,15 +20,19 @@
- text: App.tsx Read src/App.tsx
- paragraph: Now I'll update the welcome message to say Hello World instead.
- img
- text: Search & Replace App.tsx
- text: Turbo Edit App.tsx
- img
- text: "src/App.tsx Summary: Update welcome message"
- paragraph: Done! I've updated the title from 'Minimal imported app' to 'UPDATED imported app'. The change has been applied successfully.
- button:
- img
- img
- text: auto
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Undo":
- img
- button "Retry":
- img
\ No newline at end of file
......@@ -11,12 +11,13 @@ import { deleteFileTool } from "./tools/delete_file";
import { renameFileTool } from "./tools/rename_file";
import { addDependencyTool } from "./tools/add_dependency";
import { executeSqlTool } from "./tools/execute_sql";
import { searchReplaceTool } from "./tools/search_replace";
import { readFileTool } from "./tools/read_file";
import { listFilesTool } from "./tools/list_files";
import { getDatabaseSchemaTool } from "./tools/get_database_schema";
import { setChatSummaryTool } from "./tools/set_chat_summary";
import { addIntegrationTool } from "./tools/add_integration";
import { editFileTool } from "./tools/edit_file";
import {
escapeXmlAttr,
escapeXmlContent,
......@@ -28,11 +29,13 @@ import { getSupabaseClientCode } from "@/supabase_admin/supabase_context";
// Combined tool definitions array
export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
writeFileTool,
editFileTool,
deleteFileTool,
renameFileTool,
addDependencyTool,
executeSqlTool,
searchReplaceTool,
// Do not enable search-replace tool for now due to concerns around reliability
// searchReplaceTool,
readFileTool,
listFilesTool,
getDatabaseSchemaTool,
......@@ -252,10 +255,9 @@ export function buildAgentToolSet(ctx: AgentContext) {
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorStack =
error instanceof Error && error.stack ? error.stack : "";
ctx.onXmlComplete(
`<dyad-output type="error" message="Tool '${tool.name}' failed: ${escapeXmlAttr(errorMessage)}">${escapeXmlContent(errorStack || errorMessage)}</dyad-output>`,
`<dyad-output type="error" message="Tool '${tool.name}' failed: ${escapeXmlAttr(errorMessage)}">${escapeXmlContent(errorMessage)}</dyad-output>`,
);
throw error;
}
......
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { deploySupabaseFunction } from "../../../../../../supabase_admin/supabase_management_client";
import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
import { readSettings } from "@/main/settings";
const readFile = fs.promises.readFile;
const logger = log.scope("edit_file");
const DYAD_ENGINE_URL =
process.env.DYAD_ENGINE_URL ?? "https://engine.dyad.sh/v1";
const editFileSchema = z.object({
path: z.string().describe("The file path relative to the app root"),
content: z.string().describe("The updated code snippet to apply"),
description: z.string().optional().describe("Brief description of the edit"),
});
const turboFileEditResponseSchema = z.object({
result: z.string(),
});
async function callTurboFileEdit(params: {
path: string;
content: string;
originalContent: string;
description?: string;
}): Promise<string> {
const settings = readSettings();
const apiKey = settings.providerSettings?.auto?.apiKey?.value;
if (!apiKey) {
throw new Error("Dyad Pro API key is required for edit_file tool");
}
const response = await fetch(`${DYAD_ENGINE_URL}/tools/turbo-file-edit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
path: params.path,
content: params.content,
originalContent: params.originalContent,
description: params.description ?? "",
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`File edit failed: ${response.status} ${response.statusText} - ${errorText}`,
);
}
const data = turboFileEditResponseSchema.parse(await response.json());
return data.result;
}
const DESCRIPTION = `
## When to Use edit_file
Use the \`edit_file\` tool ONLY when you are editing an existing file. The edit output will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.
**Use only ONE edit_file call per file.** If you need to make multiple changes to the same file, include all edits in sequence within a single call using \`// ... existing code ...\` comments between them.
## When NOT to Use edit_file
Do NOT use this tool when:
- You are creating a brand-new file (use file creation tools instead).
- You are rewriting most of an existing file (in those cases, output the complete file instead).
## Basic Format
When writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.
Basic example:
\`\`\`
edit_file(path="file.js", description="change code", content="""
// ... existing code ...
FIRST_EDIT
// ... existing code ...
SECOND_EDIT
// ... existing code ...
THIRD_EDIT
// ... existing code ...
""")
\`\`\`
## General Principles
You should bias towards repeating as few lines of the original file as possible to convey the change.
NEVER show unmodified code in the edit, unless sufficient context of unchanged lines around the code you're editing is needed to resolve ambiguity.
DO NOT omit spans of pre-existing code without using the // ... existing code ... comment to indicate its absence.
## Example: Basic Edit
\`\`\`
edit_file(path="LandingPage.tsx", description="Update title.", content="""
// ... existing code ...
const LandingPage = () => {
// ... existing code ...
return (
<div>hello</div>
);
};
// ... existing code ...
""")
\`\`\`
## Example: Deleting Code
**When deleting code, you must provide surrounding context and leave an explicit comment indicating what was removed.**
\`\`\`
edit_file(path="utils.ts", description="Remove deprecated helper function", content="""
// ... existing code ...
export function currentHelper() {
return "active";
}
// REMOVED: deprecatedHelper() function
export function anotherHelper() {
return "working";
}
// ... existing code ...
""")
\`\`\`
`;
export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = {
name: "edit_file",
description: DESCRIPTION,
inputSchema: editFileSchema,
defaultConsent: "always",
getConsentPreview: (args) => `Edit ${args.path}`,
buildXml: (args, isComplete) => {
if (!args.path) return undefined;
let xml = `<dyad-edit path="${escapeXmlAttr(args.path)}" description="${escapeXmlAttr(args.description ?? "")}">\n${args.content ?? ""}`;
if (isComplete) {
xml += "\n</dyad-edit>";
}
return xml;
},
execute: async (args, ctx: AgentContext) => {
const fullFilePath = safeJoin(ctx.appPath, args.path);
// Track if this is a shared module
if (isSharedServerModule(args.path)) {
ctx.isSharedModulesChanged = true;
}
// Read original file content
if (!fs.existsSync(fullFilePath)) {
throw new Error(`File does not exist: ${args.path}`);
}
const originalContent = await readFile(fullFilePath, "utf8");
// Call the turbo-file-edit endpoint
const newContent = await callTurboFileEdit({
path: args.path,
content: args.content,
originalContent,
description: args.description,
});
if (!newContent) {
throw new Error(
"Failed to extract content from turbo-file-edit response",
);
}
// Ensure directory exists
const dirPath = path.dirname(fullFilePath);
fs.mkdirSync(dirPath, { recursive: true });
// Write file content
fs.writeFileSync(fullFilePath, newContent);
logger.log(`Successfully edited file: ${fullFilePath}`);
// Deploy Supabase function if applicable
if (
ctx.supabaseProjectId &&
isServerFunction(args.path) &&
!ctx.isSharedModulesChanged
) {
try {
await deploySupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: path.basename(path.dirname(args.path)),
appPath: ctx.appPath,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
} catch (error) {
return `File edited, but failed to deploy Supabase function: ${error}`;
}
}
return `Successfully edited ${args.path}`;
},
};
......@@ -187,6 +187,21 @@ app.post("/github/api/test/clear-push-events", handleClearPushEvents);
// GitHub Git endpoints - intercept all paths with /github/git prefix
app.all("/github/git/*", handleGitPush);
// Dyad Engine turbo-file-edit endpoint for edit_file tool
app.post("/engine/v1/tools/turbo-file-edit", (req, res) => {
const { path: filePath, description } = req.body;
console.log(
`* turbo-file-edit: ${filePath} - ${description || "no description"}`,
);
try {
res.json({ result: "TURBO EDITED filePath" });
} catch (error) {
console.error(`* turbo-file-edit error:`, error);
res.status(400).json({ error: String(error) });
}
});
// Start the server
const server = createServer(app);
server.listen(PORT, () => {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论