Skip to content

Commit 9823cae

Browse files
feat(tools): add edit_file tool
Implemented `edit_file` as a first-class tool with an exact single-match replacement contract. The registry now exposes it, the executor handles `path` + `oldText` + `newText`, it requires approval like `write_file`, and the prompt text now matches the actual behavior in: - `src/utils/tools.ts` - `src/constants/tool.ts` - `src/constants/prompt.ts`
1 parent 25d8d22 commit 9823cae

4 files changed

Lines changed: 171 additions & 3 deletions

File tree

src/constants/prompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ When tools return results, incorporate them into your response naturally`;
1313
export const TOOL_INSTRUCTIONS = `Available tools:
1414
- read_file: Read file contents at a path
1515
- write_file: Write content to a file (requires approval)
16-
- edit_file: Make precise edits to a file
16+
- edit_file: Replace one exact text match in a file (requires approval)
1717
- list_dir: List files in a directory
1818
- grep_search: Search code with regex
1919
- run_shell: Execute shell commands (requires approval)

src/constants/tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const NAME = {
22
READ_FILE: 'read_file',
33
WRITE_FILE: 'write_file',
4+
EDIT_FILE: 'edit_file',
45
RUN_SHELL: 'run_shell',
56
LIST_DIR: 'list_dir',
67
GREP_SEARCH: 'grep_search',

src/utils/tools.test.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ describe('tools', () => {
1818

1919
describe('TOOLS', () => {
2020
it('exports tool definitions', () => {
21-
expect(TOOLS).toHaveLength(6);
21+
expect(TOOLS).toHaveLength(7);
2222
expect(TOOLS.map((t) => t.function.name)).toContain('read_file');
2323
expect(TOOLS.map((t) => t.function.name)).toContain('write_file');
24+
expect(TOOLS.map((t) => t.function.name)).toContain('edit_file');
2425
expect(TOOLS.map((t) => t.function.name)).toContain('run_shell');
2526
expect(TOOLS.map((t) => t.function.name)).toContain('list_dir');
2627
expect(TOOLS.map((t) => t.function.name)).toContain('grep_search');
@@ -29,8 +30,9 @@ describe('tools', () => {
2930
});
3031

3132
describe('TOOLS_REQUIRING_APPROVAL', () => {
32-
it('contains write_file and run_shell', () => {
33+
it('contains write_file, edit_file, and run_shell', () => {
3334
expect(TOOLS_REQUIRING_APPROVAL.has('write_file')).toBe(true);
35+
expect(TOOLS_REQUIRING_APPROVAL.has('edit_file')).toBe(true);
3436
expect(TOOLS_REQUIRING_APPROVAL.has('run_shell')).toBe(true);
3537
expect(TOOLS_REQUIRING_APPROVAL.has('read_file' as 'write_file')).toBe(
3638
false,
@@ -66,6 +68,24 @@ describe('tools', () => {
6668
);
6769
});
6870

71+
it('executes edit_file tool', async () => {
72+
vi.mocked(existsSync).mockReturnValue(true);
73+
vi.mocked(readFileSync).mockReturnValue('before target after');
74+
75+
const result = await executeTool('edit_file', {
76+
path: '/test.txt',
77+
oldText: 'target',
78+
newText: 'updated',
79+
});
80+
81+
expect(result.content).toContain('File edited successfully');
82+
expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith(
83+
'/test.txt',
84+
'before updated after',
85+
'utf8',
86+
);
87+
});
88+
6989
it('executes list_dir tool', async () => {
7090
vi.mocked(existsSync).mockReturnValue(true);
7191
vi.mocked(readdirSync).mockReturnValue([
@@ -205,6 +225,88 @@ describe('tools', () => {
205225
});
206226
});
207227

228+
describe('editFile error handling', () => {
229+
it('returns error when file does not exist', async () => {
230+
vi.mocked(existsSync).mockReturnValue(false);
231+
232+
const result = await executeTool('edit_file', {
233+
path: '/missing.txt',
234+
oldText: 'before',
235+
newText: 'after',
236+
});
237+
expect(result.error).toContain('File not found');
238+
});
239+
240+
it('returns error when text is not found', async () => {
241+
vi.mocked(existsSync).mockReturnValue(true);
242+
vi.mocked(readFileSync).mockReturnValue('content');
243+
244+
const result = await executeTool('edit_file', {
245+
path: '/test.txt',
246+
oldText: 'missing',
247+
newText: 'after',
248+
});
249+
expect(result.error).toContain('Exact text not found');
250+
});
251+
252+
it('returns error when text matches multiple locations', async () => {
253+
vi.mocked(existsSync).mockReturnValue(true);
254+
vi.mocked(readFileSync).mockReturnValue('repeat repeat');
255+
256+
const result = await executeTool('edit_file', {
257+
path: '/test.txt',
258+
oldText: 'repeat',
259+
newText: 'after',
260+
});
261+
expect(result.error).toContain('matched multiple locations');
262+
});
263+
264+
it('returns error when read fails', async () => {
265+
vi.mocked(existsSync).mockReturnValue(true);
266+
vi.mocked(readFileSync).mockImplementation(() => {
267+
throw new Error('Permission denied');
268+
});
269+
270+
const result = await executeTool('edit_file', {
271+
path: '/test.txt',
272+
oldText: 'before',
273+
newText: 'after',
274+
});
275+
expect(result.error).toContain('Failed to edit file');
276+
});
277+
278+
it('returns error when write fails', async () => {
279+
vi.mocked(existsSync).mockReturnValue(true);
280+
vi.mocked(readFileSync).mockReturnValue('before');
281+
vi.mocked(writeFileSync).mockImplementation(() => {
282+
throw new Error('Disk full');
283+
});
284+
285+
const result = await executeTool('edit_file', {
286+
path: '/test.txt',
287+
oldText: 'before',
288+
newText: 'after',
289+
});
290+
expect(result.error).toContain('Failed to edit file');
291+
});
292+
293+
it('handles non-Error exceptions in editFile', async () => {
294+
vi.mocked(existsSync).mockReturnValue(true);
295+
vi.mocked(readFileSync).mockImplementation(() => {
296+
// eslint-disable-next-line @typescript-eslint/only-throw-error
297+
throw 'edit failed';
298+
});
299+
300+
const result = await executeTool('edit_file', {
301+
path: '/test.txt',
302+
oldText: 'before',
303+
newText: 'after',
304+
});
305+
expect(result.error).toContain('Failed to edit file');
306+
expect(result.error).toContain('edit failed');
307+
});
308+
});
309+
208310
describe('listDir error handling', () => {
209311
it('returns error when directory does not exist', async () => {
210312
vi.mocked(existsSync).mockReturnValue(false);

src/utils/tools.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@ export const TOOLS = [
5656
['path', 'content'],
5757
),
5858

59+
defineTool(
60+
TOOL.NAME.EDIT_FILE,
61+
'Replace one exact text match in an existing file at the specified path',
62+
{
63+
path: { type: 'string', description: 'The path to the file to edit' },
64+
oldText: {
65+
type: 'string',
66+
description: 'The exact existing text to replace',
67+
},
68+
newText: {
69+
type: 'string',
70+
description: 'The replacement text to write in place of oldText',
71+
},
72+
},
73+
['path', 'oldText', 'newText'],
74+
),
75+
5976
defineTool(
6077
TOOL.NAME.RUN_SHELL,
6178
'Execute a shell command',
@@ -111,6 +128,7 @@ export const TOOLS = [
111128
// for safe mode
112129
export const TOOLS_REQUIRING_APPROVAL = new Set([
113130
TOOL.NAME.WRITE_FILE,
131+
TOOL.NAME.EDIT_FILE,
114132
TOOL.NAME.RUN_SHELL,
115133
]);
116134

@@ -131,6 +149,12 @@ export async function executeTool(
131149
return readFile(args.path as string);
132150
case TOOL.NAME.WRITE_FILE:
133151
return writeFile(args.path as string, args.content as string);
152+
case TOOL.NAME.EDIT_FILE:
153+
return editFile(
154+
args.path as string,
155+
args.oldText as string,
156+
args.newText as string,
157+
);
134158
case TOOL.NAME.RUN_SHELL:
135159
return runShell(args.command as string);
136160
case TOOL.NAME.LIST_DIR:
@@ -181,6 +205,47 @@ function writeFile(filePath: string, content: string): ToolExecutionResult {
181205
}
182206
}
183207

208+
/**
209+
* Replace one exact text match in an existing file
210+
*/
211+
function editFile(
212+
filePath: string,
213+
oldText: string,
214+
newText: string,
215+
): ToolExecutionResult {
216+
try {
217+
if (!existsSync(filePath)) {
218+
return { content: '', error: `File not found: ${filePath}` };
219+
}
220+
221+
const content = readFileSync(filePath, 'utf8');
222+
223+
if (!content.includes(oldText)) {
224+
return {
225+
content: '',
226+
error: `Exact text not found in file: ${filePath}`,
227+
};
228+
}
229+
230+
const matchCount = content.split(oldText).length - 1;
231+
if (matchCount > 1) {
232+
return {
233+
content: '',
234+
error: `Exact text matched multiple locations in file: ${filePath}`,
235+
};
236+
}
237+
238+
const updatedContent = content.replace(oldText, newText);
239+
writeFileSync(filePath, updatedContent, 'utf8');
240+
return { content: `File edited successfully: ${filePath}` };
241+
} catch (error) {
242+
return {
243+
content: '',
244+
error: `Failed to edit file: ${error instanceof Error ? error.message : String(error)}`,
245+
};
246+
}
247+
}
248+
184249
// Shared shell execution options
185250
const SHELL_EXEC_OPTIONS = {
186251
timeout: 30000,

0 commit comments

Comments
 (0)