Skip to content

Commit 3195754

Browse files
committed
Improve filesystem tooling
1 parent d67b566 commit 3195754

9 files changed

Lines changed: 94 additions & 56 deletions

File tree

docs/capabilities.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
ECA support built-in tools to avoid user extra installation and configuration, these tools are always included on models requests that support tools and can be [disabled/configured via config](./configuration.md) `nativeTools`.
66

7+
Some native tools like `filesystem` have MCP alternatives, but ECA having them built-in avoid the need to external dependencies like npx.
8+
79
### Filesystem
810

9-
Provides access to filesystem under workspace root, listing and reading files and directories.
11+
Provides access to filesystem under workspace root, listing and reading files and directories a subset of [official MCP filesystem](https://mcpserverhub.com/servers/filesystem), important for agentic operations, without the need to support NPM or other tools.
1012

11-
- ''
13+
- `read_file`: read a file content.
14+
- `list_directory`: list a directory.
15+
- `search_files`: search in a path for files matching a pattern.
1216

1317
## Supported LLM models and capaibilities
1418

src/eca/features/tools.clj

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
"This ns centralizes all available tools for LLMs including
33
eca native tools and MCP servers."
44
(:require
5+
[clojure.string :as string]
56
[eca.features.tools.filesystem :as f.tools.filesystem]
67
[eca.features.tools.mcp :as f.mcp]
8+
[eca.features.tools.util :as tools.util]
79
[eca.logger :as logger])
810
(:import
911
[java.util Map]))
@@ -12,27 +14,30 @@
1214

1315
(def ^:private logger-tag "[TOOLS]")
1416

15-
(defn native-definitions [config]
17+
(defn native-definitions [db config]
1618
(merge {}
1719
(when (get-in config [:nativeTools :filesystem :enabled])
1820
(into
1921
{}
2022
(map (fn [[name tool]]
21-
[name (assoc tool :name name)]))
23+
[name (-> tool
24+
(assoc :name name)
25+
(update :description #(-> %
26+
(string/replace #"\$workspaceRoots" (tools.util/workspace-roots-strs db)))))]))
2227
f.tools.filesystem/definitions))))
2328

2429
(defn all-tools [db config]
2530
(let [native-tools (concat
2631
[]
2732
(mapv #(select-keys % [:name :description :parameters])
28-
(vals (native-definitions config))))
33+
(vals (native-definitions db config))))
2934
mcp-tools (f.mcp/all-tools db)]
3035
(concat
3136
(mapv #(assoc % :source :native) native-tools)
3237
(mapv #(assoc % :source :mcp) mcp-tools))))
3338

3439
(defn call-tool! [^String name ^Map arguments db config]
3540
(logger/debug logger-tag (format "Calling tool '%s' with args '%s'" name arguments))
36-
(if-let [native-tool-handler (get-in (native-definitions config) [name :handler])]
41+
(if-let [native-tool-handler (get-in (native-definitions db config) [name :handler])]
3742
(native-tool-handler arguments db)
3843
(f.mcp/call-tool! name arguments db)))

src/eca/features/tools/filesystem.clj

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
(:require
33
[babashka.fs :as fs]
44
[clojure.string :as string]
5+
[eca.features.tools.util :as tools.util]
56
[eca.shared :as shared]))
67

78
(set! *warn-on-reflection* true)
@@ -24,13 +25,7 @@
2425

2526
(defn ^:private path-validations [db]
2627
[["path" fs/exists? "$path is not a valid path"]
27-
["path" (partial allowed-path? db) "Access denied - path $path outside allowed directories"]])
28-
29-
(defn ^:private list-allowed-directories [_arguments db]
30-
(single-text-content
31-
(str "Allowed directories:\n"
32-
(string/join "\n"
33-
(map (comp shared/uri->filename :uri) (:workspace-folders db))))))
28+
["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
3429

3530
(defn ^:private list-directory [arguments db]
3631
(let [path (delay (fs/canonicalize (get arguments "path")))]
@@ -65,7 +60,7 @@
6560
(let [pattern (get arguments "pattern")
6661
pattern (if (string/includes? pattern "*")
6762
pattern
68-
(format "**/%s/**" pattern))
63+
(format "**/*%s*" pattern))
6964
paths (reduce
7065
(fn [paths {:keys [uri]}]
7166
(concat paths (fs/glob (shared/uri->filename uri)
@@ -77,18 +72,12 @@
7772
"No matches found")))))
7873

7974
(def definitions
80-
{"list_allowed_directories"
81-
{:description (str "Returns the list of directories that this server is allowed to access. "
82-
"Use this to understand which directories are available before trying to access files.")
83-
:parameters {:type "object"
84-
:properties {}
85-
:required []}
86-
:handler #'list-allowed-directories}
87-
"list_directory"
75+
{"list_directory"
8876
{:description (str "Get a detailed listing of all files and directories in a specified path. "
8977
"Results clearly distinguish between files and directories with [FILE] and [DIR] "
9078
"prefixes. This tool is essential for understanding directory structure and "
91-
"finding specific files within a directory. Only works within workspace root.")
79+
"finding specific files within a directory."
80+
"**Only works within the directories: $workspaceRoots.**")
9281
:parameters {:type "object"
9382
:properties {"path" {:type "string"
9483
:description "The absolute path to the directory to list."}}
@@ -100,7 +89,8 @@
10089
"if the file cannot be read. Use this tool when you need to examine "
10190
"the contents of a single file. Use the 'head' parameter to read only "
10291
"the first N lines of a file, or the 'tail' parameter to read only "
103-
"the last N lines of a file. Only works within allowed directories.")
92+
"the last N lines of a file."
93+
"**Only works within the directories: $workspaceRoots.**")
10494
:parameters {:type "object"
10595
:properties {"path" {:type "string"
10696
:description "The absolute path to the file to read."}
@@ -113,13 +103,14 @@
113103
"search_files"
114104
{:description (str "Recursively search for files and directories matching a pattern. "
115105
"Searches through all subdirectories from the starting path. The search "
116-
"is case-insensitive and matches partial names. Returns full paths to all "
106+
"is case-insensitive and matches partial names following java's FileSystem#getPathMatcher. Returns full paths to all "
117107
"matching items. Great for finding files when you don't know their exact location. "
118-
"Only searches within allowed directories.")
108+
"**Only works within the directories: $workspaceRoots.**")
119109
:parameters {:type "object"
120110
:properties {"path" {:type "string"
121111
:description "The absolute path to start searching files from there."}
122112
"pattern" {:type "string"
123-
:description "Glob pattern matching files or directory names like *.txt or dir/* to include in the result"}}
113+
:description (str "Glob pattern following java FileSystem#getPathMatcher matching files or directory names."
114+
"Use '**/*' to match search in multiple levels like '**/*.txt'")}}
124115
:required ["path" "pattern"]}
125116
:handler #'search-files}})

src/eca/features/tools/util.clj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
(ns eca.features.tools.util
2+
(:require
3+
[clojure.string :as string]
4+
[eca.shared :as shared]))
5+
6+
(defn workspace-roots-strs [db]
7+
(->> (:workspace-folders db)
8+
(map #(shared/uri->filename (:uri %)))
9+
(string/join "\n")))

src/eca/llm_providers/anthropic.clj

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[clojure.java.io :as io]
55
[eca.llm-util :as llm-util]
66
[eca.logger :as logger]
7+
[eca.shared :as shared]
78
[hato.client :as http]))
89

910
(set! *warn-on-reflection* true)
@@ -76,8 +77,12 @@
7677
past-messages)
7778
;; TODO add cache_control to last non thinking message
7879
{:role "user" :content [{:type :text
79-
:text user-prompt
80-
:cache_control {:type "ephemeral"}}]}))
80+
:text user-prompt}]}))
81+
82+
(defn ^:private add-cache-to-last-message [messages]
83+
(shared/update-last
84+
(vec messages)
85+
#(assoc-in % [:content 0 :cache_control] {:type "ephemeral"})))
8186

8287
(defn completion!
8388
[{:keys [model user-prompt temperature context max-tokens
@@ -87,7 +92,7 @@
8792
{:keys [on-message-received on-error on-prepare-tool-call on-tool-called]}]
8893
(let [messages (->messages-with-history past-messages user-prompt)
8994
body {:model model
90-
:messages messages
95+
:messages (add-cache-to-last-message messages)
9196
:max_tokens max-tokens
9297
:temperature temperature
9398
;; TODO support :thinking
@@ -129,16 +134,17 @@
129134
response (on-tool-called {:id (:id content-block)
130135
:name function-name
131136
:arguments (json/parse-string function-args)})
132-
messages (concat messages
133-
[{:role "assistant"
134-
:content [(dissoc content-block :input-json)]}]
135-
(mapv
136-
(fn [{:keys [_type content]}]
137-
{:role "user"
138-
:content [{:type "tool_result"
139-
:tool_use_id (:id content-block)
140-
:content content}]})
141-
(:contents response)))]
137+
messages (-> (concat messages
138+
[{:role "assistant"
139+
:content [(dissoc content-block :input-json)]}]
140+
(mapv
141+
(fn [{:keys [_type content]}]
142+
{:role "user"
143+
:content [{:type "tool_result"
144+
:tool_use_id (:id content-block)
145+
:content content}]})
146+
(:contents response)))
147+
add-cache-to-last-message)]
142148
(base-request!
143149
{:rid (llm-util/gen-rid)
144150
:body (assoc body :messages messages)

src/eca/shared.clj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@
1212
(-> uri Paths/get .toString
1313
;; WINDOWS drive letters
1414
(string/replace #"^[a-z]:\\" string/upper-case))))
15+
16+
(defn update-last [coll f]
17+
(if (seq coll)
18+
(update coll (dec (count coll)) f)
19+
coll))

test/eca/features/tools/filesystem_test.clj

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,6 @@
77
[eca.test-helper :as h]
88
[matcher-combinators.test :refer [match?]]))
99

10-
(deftest list-allowed-directories-test
11-
(is (match?
12-
{:contents [{:type :text
13-
:error false
14-
:content (format "Allowed directories:\n%s"
15-
(h/file-path "/foo/bar/baz"))}]}
16-
((get-in f.tools.filesystem/definitions ["list_allowed_directories" :handler])
17-
{}
18-
{:workspace-folders [{:uri (h/file-uri "file:///foo/bar/baz") :name "foo"}]}))))
19-
2010
(deftest list-directory-test
2111
(testing "Invalid path"
2212
(is (match?
@@ -32,7 +22,9 @@
3222
(is (match?
3323
{:contents [{:type :text
3424
:error true
35-
:content (format "Access denied - path %s outside allowed directories" (h/file-path "/foo/qux"))}]}
25+
:content (format "Access denied - path %s outside allowed directories: %s"
26+
(h/file-path "/foo/qux")
27+
(h/file-path "/foo/bar/baz"))}]}
3628
(with-redefs [fs/canonicalize (constantly (h/file-path "/foo/qux"))
3729
fs/exists? (constantly true)]
3830
((get-in f.tools.filesystem/definitions ["list_directory" :handler])
@@ -152,7 +144,7 @@
152144
(h/file-path "/project/foo/qux.txt"))}]}
153145
(with-redefs [fs/exists? (constantly true)
154146
fs/glob (fn [_roo pattern]
155-
(when (= "**/.txt/**" pattern)
147+
(when (= "**/*.txt*" pattern)
156148
[(fs/path (h/file-path "/project/foo/bar/baz.txt"))
157149
(fs/path (h/file-path "/project/foo/qux.txt"))]))]
158150
((get-in f.tools.filesystem/definitions ["search_files" :handler])

test/eca/features/tools_test.clj

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
(:require
33
[clojure.test :refer [deftest is testing]]
44
[eca.features.tools :as f.tools]
5+
[eca.features.tools.filesystem :as f.tools.filesystem]
6+
[eca.test-helper :as h]
57
[matcher-combinators.matchers :as m]
68
[matcher-combinators.test :refer [match?]]))
79

@@ -29,4 +31,13 @@
2931
(testing "Do not include disabled native tools"
3032
(is (match?
3133
(m/embeds [(m/mismatch {:name "list_directory"})])
32-
(f.tools/all-tools {} {:nativeTools {:filesystem {:enabled false}}})))))
34+
(f.tools/all-tools {} {:nativeTools {:filesystem {:enabled false}}}))))
35+
(testing "Replace special vars description"
36+
(is (match?
37+
(m/embeds [{:name "list_directory"
38+
:description (format "Only in %s" (h/file-path "/path/to/project/foo"))
39+
:parameters some?
40+
:source :native}])
41+
(with-redefs [f.tools.filesystem/definitions {"list_directory" {:description "Only in $workspaceRoots"
42+
:parameters {}}}]
43+
(f.tools/all-tools {:workspace-folders [{:name "foo" :uri (h/file-uri "file:///path/to/project/foo")}]} {:nativeTools {:filesystem {:enabled true}}}))))))

test/eca/llm_providers/anthropic_test.clj

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
(deftest ->messages-with-history-test
88
(testing "no previous history"
99
(is (match?
10-
[{:role "user" :content [{:type :text :text "Hey" :cache_control {:type "ephemeral"}}]}]
10+
[{:role "user" :content [{:type :text :text "Hey"}]}]
1111
(#'llm-providers.anthropic/->messages-with-history [] "Hey"))))
1212
(testing "With basic text history"
1313
(is (match?
1414
[{:role "user" :content "Count with me: 1"}
1515
{:role "assistant" :content "2"}
16-
{:role "user" :content [{:type :text :text "3" :cache_control {:type "ephemeral"}}]}]
16+
{:role "user" :content [{:type :text :text "3"}]}]
1717
(#'llm-providers.anthropic/->messages-with-history
1818
[{:role "user" :content "Count with me: 1"}
1919
{:role "assistant" :content "2"}]
@@ -30,7 +30,7 @@
3030
:tool_use_id "call-1"
3131
:content "Allowed directories: /foo/bar\n"}]}
3232
{:role "assistant" :content "I see /foo/bar"}
33-
{:role "user" :content [{:type :text :text "Thanks" :cache_control {:type "ephemeral"}}]}]
33+
{:role "user" :content [{:type :text :text "Thanks"}]}]
3434
(#'llm-providers.anthropic/->messages-with-history
3535
[{:role "user" :content "List the files you are allowed"}
3636
{:role "assistant" :content "Ok!"}
@@ -43,3 +43,18 @@
4343
:content "Allowed directories: /foo/bar"}]}}}
4444
{:role "assistant" :content "I see /foo/bar"}]
4545
"Thanks")))))
46+
47+
(deftest add-cache-to-last-message-test
48+
(is (match?
49+
[]
50+
(#'llm-providers.anthropic/add-cache-to-last-message [])))
51+
(is (match?
52+
[{:role "user" :content [{:type :text :text "Hey" :cache_control {:type "ephemeral"}}]}]
53+
(#'llm-providers.anthropic/add-cache-to-last-message
54+
[{:role "user" :content [{:type :text :text "Hey"}]}])))
55+
(is (match?
56+
[{:role "user" :content [{:type :text :text "Hey"}]}
57+
{:role "user" :content [{:type :text :text "Ho" :cache_control {:type "ephemeral"}}]}]
58+
(#'llm-providers.anthropic/add-cache-to-last-message
59+
[{:role "user" :content [{:type :text :text "Hey"}]}
60+
{:role "user" :content [{:type :text :text "Ho"}]}]))))

0 commit comments

Comments
 (0)