Skip to content

Commit aba1db9

Browse files
committed
Support recursive AGENTS.md file inclusions with @file mention.
Fixes #140
1 parent 0e047f2 commit aba1db9

File tree

5 files changed

+195
-13
lines changed

5 files changed

+195
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Obfuscate env vars in /doctor.
77
- Bump clj-otel to 0.2.10
88
- Rename $ARGS to $ARGUMENTS placeholder alias for custom commands.
9+
- Support recursive AGENTS.md file inclusions with @file mention. #140
910

1011
## 0.66.1
1112

src/eca/features/chat.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,7 @@
863863
(send-content! {:messenger messenger :chat-id chat-id} :system {:type :progress
864864
:state :running
865865
:text "Parsing given context"}))
866-
refined-contexts (f.context/raw-contexts->refined contexts db config)
866+
refined-contexts (f.context/raw-contexts->refined contexts db)
867867
repo-map* (delay (f.index/repo-map db config {:as-string? true}))
868868
instructions (f.prompt/build-instructions refined-contexts
869869
rules

src/eca/features/context.clj

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,51 @@
1515

1616
(def ^:private logger-tag "[CONTEXT]")
1717

18+
(defn ^:private extract-at-mentions
19+
"Extract all @path mentions from content. Supports relative and absolute paths with any extension."
20+
[content]
21+
(let [pattern #"@([^\s\)]+\.\w+)"
22+
matches (re-seq pattern content)]
23+
(map second matches)))
24+
25+
(defn ^:private parse-agents-file
26+
([path] (parse-agents-file path #{}))
27+
([path visited]
28+
(if (contains? visited path)
29+
[]
30+
(let [root-content (llm-api/refine-file-context path nil)
31+
visited' (conj visited path)
32+
at-mentions (extract-at-mentions root-content)
33+
parent-dir (str (fs/parent path))
34+
resolved-paths (map (fn [mention]
35+
(cond
36+
;; Absolute path
37+
(string/starts-with? mention "/")
38+
mention
39+
40+
;; Relative path (./... or ../...)
41+
(or (string/starts-with? mention "./")
42+
(string/starts-with? mention "../"))
43+
(str (fs/canonicalize (fs/file parent-dir mention)))
44+
45+
;; Simple filename, relative to current file's directory
46+
:else
47+
(str (fs/canonicalize (fs/file parent-dir mention)))))
48+
at-mentions)
49+
;; Deduplicate resolved paths
50+
unique-paths (distinct resolved-paths)
51+
;; Recursively parse all mentioned files
52+
nested-results (mapcat #(parse-agents-file % visited') unique-paths)]
53+
(concat [{:type :agents-file
54+
:path path
55+
:content root-content}]
56+
nested-results)))))
57+
1858
(defn ^:private agents-file-contexts
19-
"Search for AGENTS.md file both in workspaceRoot and global config dir."
20-
[db _config]
59+
"Search for AGENTS.md file both in workspaceRoot and global config dir.
60+
Process any found @paths mentions recursively, supporting both relative and absolute paths.
61+
Deduplicates files to avoid reading the same file multiple times."
62+
[db]
2163
;; TODO make it customizable by behavior
2264
(let [agent-file "AGENTS.md"
2365
local-agent-files (keep (fn [{:keys [uri]}]
@@ -28,13 +70,9 @@
2870
global-agent-file (let [agent-file (fs/path (config/global-config-dir) agent-file)]
2971
(when (fs/readable? agent-file)
3072
(fs/canonicalize agent-file)))]
31-
(mapv (fn [path]
32-
{:type :file
33-
:path (str path)
34-
:partial false
35-
:content (llm-api/refine-file-context (str path) nil)})
36-
(concat local-agent-files
37-
(when global-agent-file [global-agent-file])))))
73+
(->> (concat local-agent-files
74+
(when global-agent-file [global-agent-file]))
75+
(mapcat #(parse-agents-file (str %))))))
3876

3977
(defn ^:private file->refined-context [path lines-range]
4078
(let [ext (string/lower-case (fs/extension path))]
@@ -52,8 +90,8 @@
5290
:content (llm-api/refine-file-context path lines-range)}
5391
:partial lines-range))))
5492

55-
(defn raw-contexts->refined [contexts db config]
56-
(concat (agents-file-contexts db config)
93+
(defn raw-contexts->refined [contexts db]
94+
(concat (agents-file-contexts db)
5795
(mapcat (fn [{:keys [type path lines-range position uri]}]
5896
(case (name type)
5997
"file" [(file->refined-context path lines-range)]

src/eca/features/prompt.clj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@
7979
:file (if partial
8080
(format "<file partial=true path=\"%s\">...\n%s\n...</file>\n" path content)
8181
(format "<file path=\"%s\">%s</file>\n" path content))
82+
:agents-file (multi-str
83+
(format "<agents-file description=\"Instructions following AGENTS.md spec.\" path=\"%s\">" path)
84+
content
85+
"</agents-file>\n")
8286
:repoMap (format "<repoMap description=\"Workspaces structure in a tree view, spaces represent file hierarchy\" >%s</repoMap>\n" @repo-map*)
8387
:cursor (format "<cursor description=\"User editor cursor position (line:character)\" path=\"%s\" start=\"%s\" end=\"%s\"/>\n"
8488
path

test/eca/features/context_test.clj

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
[eca.features.context :as f.context]
77
[eca.features.index :as f.index]
88
[eca.features.tools.mcp :as f.mcp]
9+
[eca.llm-api :as llm-api]
10+
[eca.shared :refer [multi-str]]
911
[eca.test-helper :as h]
10-
[matcher-combinators.test :refer [match?]]))
12+
[matcher-combinators.test :refer [match?]]
13+
[matcher-combinators.matchers :as m]))
1114

1215
(h/reset-components-before-test)
1316

@@ -182,3 +185,139 @@
182185
;; Entries from home listing
183186
(is (some #(= {:type "file" :path (str home "/.bashrc")} %) result))
184187
(is (some #(= {:type "directory" :path (str home "/projects")} %) result)))))))
188+
189+
(deftest parse-agents-file-test
190+
(testing "simple agents file with no path inclusion"
191+
(let [a-file (h/file-path "/fake/AGENTS.md")
192+
a-content (multi-str
193+
"- do foo")]
194+
(with-redefs [llm-api/refine-file-context (fn [p _l]
195+
(condp = p
196+
a-file a-content))]
197+
(is (match?
198+
(m/in-any-order
199+
[{:type :agents-file
200+
:path a-file
201+
:content a-content}])
202+
(#'f.context/parse-agents-file a-file))))))
203+
(testing "Single relative path inclusion"
204+
(let [a-file (h/file-path "/fake/AGENTS.md")
205+
a-content (multi-str
206+
"- do foo"
207+
"- follow @b.md")
208+
b-file (h/file-path "/fake/b.md")
209+
b-content (multi-str
210+
"- do bar")]
211+
(with-redefs [llm-api/refine-file-context (fn [p _l]
212+
(condp = p
213+
a-file a-content
214+
b-file b-content))]
215+
(is (match?
216+
(m/in-any-order
217+
[{:type :agents-file
218+
:path a-file
219+
:content a-content}
220+
{:type :agents-file
221+
:path b-file
222+
:content b-content}])
223+
(#'f.context/parse-agents-file a-file))))))
224+
(testing "Single absolute path inclusion"
225+
(let [a-file (h/file-path "/fake/AGENTS.md")
226+
a-content (multi-str
227+
"- do foo"
228+
"@/fake/src/b.md is where the nice things live")
229+
b-file (h/file-path "/fake/src/b.md")
230+
b-content (multi-str
231+
"- do bar")]
232+
(with-redefs [llm-api/refine-file-context (fn [p _l]
233+
(condp = p
234+
a-file a-content
235+
b-file b-content))]
236+
(is (match?
237+
(m/in-any-order
238+
[{:type :agents-file
239+
:path a-file
240+
:content a-content}
241+
{:type :agents-file
242+
:path b-file
243+
:content b-content}])
244+
(#'f.context/parse-agents-file a-file))))))
245+
(testing "Multiple path inclusions with different extensions"
246+
(let [a-file (h/file-path "/fake/AGENTS.md")
247+
a-content (multi-str
248+
"- do foo"
249+
"- check @./src/b.md for b things"
250+
"- also follow @../c.txt")
251+
b-file (h/file-path "/fake/src/b.md")
252+
b-content (multi-str
253+
"- do bar")
254+
c-file (h/file-path "/c.txt")
255+
c-content (multi-str
256+
"- do bazzz")]
257+
(with-redefs [llm-api/refine-file-context (fn [p _l]
258+
(condp = p
259+
a-file a-content
260+
b-file b-content
261+
c-file c-content))]
262+
(is (match?
263+
(m/in-any-order
264+
[{:type :agents-file
265+
:path a-file
266+
:content a-content}
267+
{:type :agents-file
268+
:path b-file
269+
:content b-content}
270+
{:type :agents-file
271+
:path c-file
272+
:content c-content}])
273+
(#'f.context/parse-agents-file a-file))))))
274+
(testing "Recursive path inclusions"
275+
(let [a-file (h/file-path "/fake/AGENTS.md")
276+
a-content (multi-str
277+
"- do foo"
278+
"- check @b.md for b things")
279+
b-file (h/file-path "/fake/b.md")
280+
b-content (multi-str
281+
"- check @../c.md")
282+
c-file (h/file-path "/c.md")
283+
c-content (multi-str
284+
"- do bazzz")]
285+
(with-redefs [llm-api/refine-file-context (fn [p _l]
286+
(condp = p
287+
a-file a-content
288+
b-file b-content
289+
c-file c-content))]
290+
(is (match?
291+
(m/in-any-order
292+
[{:type :agents-file
293+
:path a-file
294+
:content a-content}
295+
{:type :agents-file
296+
:path b-file
297+
:content b-content}
298+
{:type :agents-file
299+
:path c-file
300+
:content c-content}])
301+
(#'f.context/parse-agents-file a-file))))))
302+
(testing "Multiple mentions to same file include it once"
303+
(let [a-file (h/file-path "/fake/AGENTS.md")
304+
a-content (multi-str
305+
"- do foo"
306+
"- check @b.md for b things"
307+
"- make sure you check @b.md for sure")
308+
b-file (h/file-path "/fake/b.md")
309+
b-content (multi-str
310+
"- yeah")]
311+
(with-redefs [llm-api/refine-file-context (fn [p _l]
312+
(condp = p
313+
a-file a-content
314+
b-file b-content))]
315+
(is (match?
316+
(m/in-any-order
317+
[{:type :agents-file
318+
:path a-file
319+
:content a-content}
320+
{:type :agents-file
321+
:path b-file
322+
:content b-content}])
323+
(#'f.context/parse-agents-file a-file)))))))

0 commit comments

Comments
 (0)