Skip to content

Commit 66961e5

Browse files
committed
Improve eca_edit_file tool for better usage from LLM
1 parent 72b7b1c commit 66961e5

File tree

3 files changed

+64
-81
lines changed

3 files changed

+64
-81
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Improve `eca_edit_file` tool for better usage from LLM.
6+
57
## 0.15.1
68

79
- Fix mcp tool calls.

src/eca/features/tools/filesystem.clj

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
[["path" fs/exists? "$path is not a valid path"]
1818
["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
1919

20-
(defn ^:private list-directory [arguments {:keys [db]}]
20+
(defn ^:private directory-tree [arguments {:keys [db]}]
2121
(let [path (delay (fs/canonicalize (get arguments "path")))]
2222
(or (tools.util/invalid-arguments arguments (path-validations db))
2323
(tools.util/single-text-content
@@ -165,21 +165,19 @@
165165

166166
(defn ^:private edit-file [arguments {:keys [db]}]
167167
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
168-
[["path" fs/readable? "File $path is not readable"]
169-
["start_line" #(and (integer? %) (pos? %)) "$start_line must be a positive integer"]
170-
["end_line" #(and (integer? %) (pos? %)) "$end_line must be a positive integer"]
171-
["content" #(string? %) "$content must be a string"]]))
168+
[["path" fs/readable? "File $path is not readable"]]))
172169
(let [path (get arguments "path")
173-
start-line (dec (int (get arguments "start_line"))) ; convert 1-based to 0-based
174-
end-line (dec (int (get arguments "end_line"))) ; inclusive, 0-based
175-
new-lines (string/split-lines (get arguments "content"))
176-
old-lines (vec (string/split-lines (slurp path)))
177-
before (subvec old-lines 0 start-line)
178-
after (subvec old-lines (inc end-line))
179-
new-content-lines (vec (concat before new-lines after))
180-
new-file-content (string/join "\n" new-content-lines)]
181-
(spit path new-file-content)
182-
(tools.util/single-text-content (format "Successfully replaced lines %d-%d in %s." (inc start-line) (inc end-line) path)))))
170+
original-content (get arguments "original_content")
171+
new-content (get arguments "new_content")
172+
all? (boolean (get arguments "all_occurrences"))
173+
content (slurp path)]
174+
(if (string/includes? content original-content)
175+
(let [content (if all?
176+
(string/replace content original-content new-content)
177+
(string/replace-first content original-content new-content))]
178+
(spit path content)
179+
(tools.util/single-text-content (format "Successfully replaced content in %s." path)))
180+
(tools.util/single-text-content (format "Original content not found in %s" path) :error)))))
183181

184182
(defn ^:private move-file [arguments {:keys [db]}]
185183
(let [workspace-dirs (tools.util/workspace-roots-strs db)]
@@ -193,22 +191,25 @@
193191
(tools.util/single-text-content (format "Successfully moved %s to %s" source destination))))))
194192

195193
(def definitions
196-
{"eca_list_directory"
197-
{:description (str "Get a detailed listing of all files and directories in a specified path. "
198-
"Results clearly distinguish between files and directories with [FILE] and [DIR] "
199-
"prefixes. This tool is essential for understanding directory structure and "
200-
"finding specific files within a directory."
194+
{"eca_directory_tree"
195+
{:description (str "Returns a recursive tree view of files and directories starting from the specified path. "
196+
"The path parameter must be an absolute path, not a relative path. "
201197
"**Only works within the directories: $workspaceRoots.**")
202198
:parameters {:type "object"
203199
:properties {"path" {:type "string"
204-
:description "The absolute path to the directory to list."}}
200+
:description "The absolute path to the directory."}
201+
"max_depth" {:type "integer"
202+
:description "Maximum depth to traverse (optional)"}
203+
"limit" {:type "integer"
204+
:description "Maxium number of entries to show (default: 100)"}}
205205
:required ["path"]}
206-
:handler #'list-directory}
206+
:handler #'directory-tree}
207207
"eca_read_file"
208208
{:description (str "Read the contents of a file from the file system. "
209209
"Use this tool when you need to examine "
210210
"the contents of a single file. Optionally use the 'line_offset' and/or 'limit' "
211-
"parameters to read specific contents of the file when you know the range."
211+
"parameters to read specific contents of the file when you know the range. "
212+
"Prefer call once this tool over multiple calls passing small offsets. "
212213
"**Only works within the directories: $workspaceRoots.**")
213214
:parameters {:type "object"
214215
:properties {"path" {:type "string"
@@ -230,19 +231,21 @@
230231
:required ["path" "content"]}
231232
:handler #'write-file}
232233
"eca_edit_file"
233-
{:description (str "Change the specified file replacing the lines range with the given content. "
234-
"Make sure to have the existing file content updated via your context or eca_read_file before calling this. "
235-
"This can also be used to prepend, append, or delete contents from a file.")
234+
{:description (str "Replace a specific string or content block in a file with new content. "
235+
"Finds the exact original content and replaces it with new content. "
236+
"Be extra careful to format the original-content exactly correctly, "
237+
"taking extra care with whitespace and newlines. In addition to replacing strings, "
238+
"this can also be used to prepend, append, or delete contents from a file.")
236239
:parameters {:type "object"
237240
:properties {"path" {:type "string"
238241
:description "The absolute file path to do the replace."}
239-
"start_line" {:type "number"
240-
:description "The 1-based start line number"}
241-
"end_line" {:type "number"
242-
:description "The 1-based end line number"}
243-
"content" {:type "string"
244-
:description "The new content to be inserted"}}
245-
:required ["path" "start_line" "end_line" "content"]}
242+
"original_content" {:type "string"
243+
:description "The exact content to find and replace"}
244+
"new_content" {:type "string"
245+
:description "The new content to replace the original content with"}
246+
"all_occurrences" {:type "boolean"
247+
:description "Whether to replace all occurences of the file or just the first one (default)"}}
248+
:required ["path" "original_content" "new_content"]}
246249
:handler #'edit-file}
247250
"eca_move_file"
248251
{:description (str "Move or rename files and directories. Can move files between directories "

test/eca/features/tools/filesystem_test.clj

Lines changed: 26 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@
259259
"pattern" "some-cool-content"}
260260
{:db {:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}}))))))
261261

262-
(deftest replace-in-file-test
262+
(deftest edit-file-test
263263
(testing "Not readable path"
264264
(is (match?
265265
{:error true
@@ -270,86 +270,64 @@
270270
f.tools.filesystem/allowed-path? (constantly true)]
271271
((get-in f.tools.filesystem/definitions ["eca_edit_file" :handler])
272272
{"path" (h/file-path "/foo/qux")
273-
"start_line" 2
274-
"end_line" 3
275-
"content" "updated"}
273+
"original_content" "foo"
274+
"new_content" "bar"}
276275
{:db {:workspace-folders [{:uri (h/file-uri "file:///foo/bar/baz") :name "foo"}]}})))))
277276

278-
(testing "invalid line range (start_line > end_line)"
279-
(is (thrown?
280-
Exception
277+
(testing "Original content not found"
278+
(is (match?
279+
{:error true
280+
:contents [{:type :text
281+
:text (format "Original content not found in %s" (h/file-path "/foo/bar/my-file.txt"))}]}
281282
(with-redefs [fs/exists? (constantly true)
282283
fs/readable? (constantly true)
283284
f.tools.filesystem/allowed-path? (constantly true)
284-
slurp (constantly "a\nb\nc")] ; just for completeness
285+
slurp (constantly "line1\nline2\nline3")]
285286
((get-in f.tools.filesystem/definitions ["eca_edit_file" :handler])
286-
{"path" (h/file-path "/foo/qux")
287-
"start_line" 3
288-
"end_line" 2
289-
"content" "no-op"}
290-
{:db {:workspace-folders [{:uri (h/file-uri "file:///foo/bar") :name "bar"}]}})))))
291-
292-
(testing "replace 2nd and 3rd lines in file"
293-
(let [file-content* (atom {})]
294-
(is (match?
295-
{:error false
296-
:contents [{:type :text
297-
:text (format "Successfully replaced lines 2-3 in %s." (h/file-path "/project/foo/my-file.txt"))}]}
298-
(with-redefs [fs/exists? (constantly true)
299-
fs/readable? (constantly true)
300-
f.tools.filesystem/allowed-path? (constantly true)
301-
slurp (constantly "line1\nold2\nold3\nline4")
302-
spit (fn [f content] (swap! file-content* assoc f content))]
303-
((get-in f.tools.filesystem/definitions ["eca_edit_file" :handler])
304-
{"path" (h/file-path "/project/foo/my-file.txt")
305-
"start_line" 2
306-
"end_line" 3
307-
"content" "new2\nnew3"}
308-
{:db {:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}}))))
309-
(is (match?
310-
{(h/file-path "/project/foo/my-file.txt") "line1\nnew2\nnew3\nline4"}
311-
@file-content*))))
287+
{"path" (h/file-path "/foo/bar/my-file.txt")
288+
"original_content" "notfound"
289+
"new_content" "new"}
290+
{:db {:workspace-folders [{:uri (h/file-uri "file:///foo/bar") :name "foo"}]}})))))
312291

313-
(testing "replace first line only in file"
292+
(testing "Replace first occurrence only"
314293
(let [file-content* (atom {})]
315294
(is (match?
316295
{:error false
317296
:contents [{:type :text
318-
:text (format "Successfully replaced lines 1-1 in %s." (h/file-path "/project/foo/my-file.txt"))}]}
297+
:text (format "Successfully replaced content in %s." (h/file-path "/project/foo/my-file.txt"))}]}
319298
(with-redefs [fs/exists? (constantly true)
320299
fs/readable? (constantly true)
321300
f.tools.filesystem/allowed-path? (constantly true)
322-
slurp (constantly "orig1\nkeep2\nkeep3")
301+
slurp (constantly "a b a c")
323302
spit (fn [f content] (swap! file-content* assoc f content))]
324303
((get-in f.tools.filesystem/definitions ["eca_edit_file" :handler])
325304
{"path" (h/file-path "/project/foo/my-file.txt")
326-
"start_line" 1
327-
"end_line" 1
328-
"content" "updated1"}
305+
"original_content" "a"
306+
"new_content" "X"}
329307
{:db {:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}}))))
330308
(is (match?
331-
{(h/file-path "/project/foo/my-file.txt") "updated1\nkeep2\nkeep3"}
309+
{(h/file-path "/project/foo/my-file.txt") "X b a c"}
332310
@file-content*))))
333311

334-
(testing "replace last line in file"
312+
(testing "Replace all occurrences"
335313
(let [file-content* (atom {})]
336314
(is (match?
337315
{:error false
338316
:contents [{:type :text
339-
:text (format "Successfully replaced lines 4-4 in %s." (h/file-path "/project/foo/my-file.txt"))}]}
317+
:text (format "Successfully replaced content in %s." (h/file-path "/project/foo/my-file.txt"))}]}
340318
(with-redefs [fs/exists? (constantly true)
341319
fs/readable? (constantly true)
342320
f.tools.filesystem/allowed-path? (constantly true)
343-
slurp (constantly "keep1\nkeep2\nkeep3\nlast")
321+
slurp (constantly "foo bar foo baz foo")
344322
spit (fn [f content] (swap! file-content* assoc f content))]
345323
((get-in f.tools.filesystem/definitions ["eca_edit_file" :handler])
346324
{"path" (h/file-path "/project/foo/my-file.txt")
347-
"start_line" 4
348-
"end_line" 4
349-
"content" "newlast"}
325+
"original_content" "foo"
326+
"new_content" "Z"
327+
"all_occurrences" true}
350328
{:db {:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}}))))
351329
(is (match?
352-
{(h/file-path "/project/foo/my-file.txt") "keep1\nkeep2\nkeep3\nnewlast"}
330+
{(h/file-path "/project/foo/my-file.txt") "Z bar Z baz Z"}
353331
@file-content*)))))
354332

355333
(deftest move-file-test

0 commit comments

Comments
 (0)