Skip to content

Commit 15ef114

Browse files
authored
Merge pull request #105 from CsBigDataHub/master
custom tools support
2 parents 2b02a9a + 9a6602d commit 15ef114

File tree

6 files changed

+176
-1
lines changed

6 files changed

+176
-1
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+
- Support user configured custom tools.
6+
57
## 0.44.1
68

79
- Fix renew token regression.

docs/configuration.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,60 @@ Also check the `plan` behavior which is safer.
158158

159159
__The `manualApproval` setting was deprecated and replaced by the `approval` one without breaking changes__
160160

161+
## Custom Tools
162+
163+
You can define your own command-line tools that the LLM can use. These are configured via the `custom-tools` key in your `config.json`.
164+
165+
The `custom-tools` value is an object where each key is the name of your tool. Each tool definition has the following properties:
166+
167+
- `description`: A clear description of what the tool does. This is crucial for the LLM to decide when to use it.
168+
- `command`: An array of strings representing the command and its static arguments.
169+
- `schema`: An object that defines the parameters the LLM can provide.
170+
- `properties`: An object where each key is an argument name.
171+
- `required`: An array of required argument names.
172+
173+
Placeholders in the format `{{argument_name}}` within the `command` array will be replaced by the values provided by the LLM.
174+
175+
=== "Example config.json"
176+
177+
```javascript
178+
{
179+
"custom-tools": {
180+
"web-search": {
181+
"description": "Fetches the content of a URL and returns it in Markdown format.",
182+
"command": ["trafilatura", "--output-format=markdown", "-u", "{{url}}"],
183+
"schema": {
184+
"properties": {
185+
"url": {
186+
"type": "string",
187+
"description": "The URL to fetch content from."
188+
}
189+
},
190+
"required": ["url"]
191+
}
192+
},
193+
"file-search": {
194+
"description": "Finds files within a directory that match a specific name pattern.",
195+
"command": ["find", "{{directory}}", "-name", "{{pattern}}"],
196+
"schema": {
197+
"properties": {
198+
"directory": {
199+
"type": "string",
200+
"description": "The directory to start the search from."
201+
},
202+
"pattern": {
203+
"type": "string",
204+
"description": "The search pattern for the filename (e.g., '*.clj')."
205+
}
206+
},
207+
"required": ["directory", "pattern"]
208+
}
209+
}
210+
}
211+
}
212+
```
213+
214+
161215
## Custom command prompts
162216

163217
You can configure custom command prompts for project, global or via `commands` config pointing to the path of the commands.
@@ -265,6 +319,17 @@ There are 3 possible ways to configure rules following this order of priority:
265319
excludeCommands: string[]};
266320
editor: {enabled: boolean,};
267321
};
322+
customTools?: {[key: string]: {
323+
description: string;
324+
command: string[];
325+
schema: {
326+
properties: {[key: string]: {
327+
type: string;
328+
description: string;
329+
}};
330+
required: string[];
331+
};
332+
}};
268333
disabledTools?: string[],
269334
toolCall?: {
270335
approval?: {

docs/features.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ Provides access to get information from editor workspaces.
5252

5353
- `eca_editor_diagnostics`: Ask client about the diagnostics (like LSP diagnostics).
5454

55+
#### Custom Tools
56+
57+
Besides the built-in native tools, ECA allows you to define your own tools by wrapping any command-line executable. This feature enables you to extend ECA's capabilities to match your specific workflows, such as running custom scripts, interacting with internal services, or using your favorite CLI tools.
58+
59+
Custom tools are configured in your `config.json` file. For a detailed guide on how to set them up, check the [Custom Tools configuration documentation](./configuration.md#custom-tools).
60+
5561
### Contexts
5662

5763
![](./images/features/contexts.png)

src/eca/features/tools.clj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
eca native tools and MCP servers."
44
(:require
55
[clojure.string :as string]
6+
[eca.features.tools.custom :as f.tools.custom]
67
[eca.features.tools.editor :as f.tools.editor]
78
[eca.features.tools.filesystem :as f.tools.filesystem]
89
[eca.features.tools.mcp :as f.mcp]
@@ -33,7 +34,8 @@
3334
(when (get-in config [:nativeTools :shell :enabled])
3435
f.tools.shell/definitions)
3536
(when (get-in config [:nativeTools :editor :enabled])
36-
f.tools.editor/definitions))))
37+
f.tools.editor/definitions)
38+
(f.tools.custom/definitions config))))
3739

3840
(defn ^:private native-tools [db config]
3941
(mapv #(assoc % :server "eca") (vals (native-definitions db config))))

src/eca/features/tools/custom.clj

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
(ns eca.features.tools.custom
2+
(:require
3+
[babashka.process :as process]
4+
[clojure.string :as string]))
5+
6+
(set! *warn-on-reflection* true)
7+
8+
(defn ^:private build-tool-fn
9+
"Creates a function that safely executes the command from a custom tool config.
10+
It substitutes {{placeholders}} in the command vector with LLM-provided arguments."
11+
[{:keys [command]}]
12+
;; The handler function takes arguments and a context map. We only need the arguments.
13+
(fn [llm-args _context]
14+
(let [resolved-command (mapv
15+
(fn [part]
16+
(if (and (string? part) (string/starts-with? part "{{") (string/ends-with? part "}}"))
17+
(let [key-name (keyword (subs part 2 (- (count part) 2)))]
18+
(str (get llm-args key-name "")))
19+
part))
20+
command)
21+
{:keys [out exit]} (process/sh resolved-command {:error-to-out true})]
22+
(if (zero? exit)
23+
out
24+
(str "Error: Command failed with exit code " exit "\nOutput:\n" out)))))
25+
26+
(defn ^:private custom-tool->tool-def
27+
"Transforms a single custom tool from the config map into a full tool definition."
28+
[[tool-name tool-config]]
29+
(let [schema (:schema tool-config)]
30+
{(name tool-name)
31+
{:name (name tool-name)
32+
:description (:description tool-config)
33+
:parameters {:type "object"
34+
:properties (update-keys (:properties schema) keyword)
35+
:required (mapv keyword (:required schema))}
36+
:handler (build-tool-fn tool-config)}}))
37+
38+
(defn definitions
39+
"Loads all custom tools from the config."
40+
[config]
41+
(->> (get config :custom-tools {})
42+
(map custom-tool->tool-def)
43+
(apply merge)))
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
(ns eca.features.tools.custom-test
2+
(:require
3+
[clojure.string :as string]
4+
[clojure.test :refer [deftest is testing]]
5+
[babashka.process :as process]
6+
[eca.features.tools.custom :as f.tools.custom]))
7+
8+
(deftest definitions-test
9+
(testing "when a valid tool is configured"
10+
(let [mock-custom-tools {"file-search"
11+
{:description "Finds files."
12+
:command ["find" "{{directory}}" "-name" "{{pattern}}"]
13+
:schema {:properties {:directory {:type "string"}
14+
:pattern {:type "string"}}
15+
:required ["directory" "pattern"]}}}]
16+
(testing "and the command executes successfully"
17+
(with-redefs [process/sh (fn [command-vec & _]
18+
(is (= ["find" "/tmp" "-name" "*.clj"] command-vec))
19+
{:out "mocked-output" :exit 0})]
20+
(let [config {:custom-tools mock-custom-tools}
21+
custom-defs (f.tools.custom/definitions config)
22+
custom-tool-def (get custom-defs "file-search")]
23+
(is (some? custom-tool-def) "The custom tool should be loaded.")
24+
(let [result ((:handler custom-tool-def) {:directory "/tmp" :pattern "*.clj"} {})]
25+
(is (= "mocked-output" result) "The tool should return the mocked shell output.")))))))
26+
27+
(testing "when multiple tools are configured"
28+
(let [mock-custom-tools {"git-status"
29+
{:description "Gets git status"
30+
:command ["git" "status"]}
31+
"echo-message"
32+
{:description "Echoes a message"
33+
:command ["echo" "{{message}}"]
34+
:schema {:properties {:message {:type "string"}} :required ["message"]}}}]
35+
(with-redefs [process/sh (fn [command-vec & _]
36+
(condp = command-vec
37+
["git" "status"] {:out "On branch main" :exit 0}
38+
["echo" "Hello World"] {:out "Hello World" :exit 0}
39+
(is false "Unexpected command received by mock p/sh")))]
40+
(let [config {:custom-tools mock-custom-tools}
41+
custom-defs (f.tools.custom/definitions config)
42+
git-status-handler (get-in custom-defs ["git-status" :handler])
43+
echo-handler (get-in custom-defs ["echo-message" :handler])]
44+
(is (some? git-status-handler) "Git status tool should be loaded.")
45+
(is (some? echo-handler) "Echo message tool should be loaded.")
46+
(is (= "On branch main" (git-status-handler {} {})))
47+
(is (= "Hello World" (echo-handler {:message "Hello World"} {})))))))
48+
49+
(testing "when the custom tools config is empty or missing"
50+
(testing "with an empty map"
51+
(let [config {:custom-tools {}}
52+
custom-defs (f.tools.custom/definitions config)]
53+
(is (empty? custom-defs) "No custom tools should be loaded.")))
54+
(testing "with the key missing from the config"
55+
(let [config {}
56+
custom-defs (f.tools.custom/definitions config)]
57+
(is (empty? custom-defs) "No custom tools should be loaded.")))))

0 commit comments

Comments
 (0)