Skip to content

Commit 96ce5fb

Browse files
committed
Accept tools: as a YAML list in markdown agents (Claude form)
Markdown agents written for Claude Code or OpenCode commonly express allowed tools as a flat list (tools: [read, search, agent]) rather than as ECA's byDefault/allow/deny map. The parser previously called (into {} <list-of-strings>), which throws, and the exception was swallowed by agent-md-file->agent, silently dropping the whole agent. The tools field is now normalized: map form is unchanged, sequential form is wrapped as byDefault: ask + allow: <list>, and any other shape is ignored without crashing. Adds unit tests plus an end-to-end plugin test using the reporter's exact frontmatter.
1 parent 70d68df commit 96ce5fb

5 files changed

Lines changed: 169 additions & 22 deletions

File tree

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+
- Markdown agents now accept `tools:` as a flat YAML list (Claude convention), normalized to `byDefault: ask` + `allow`. Previously such agents were silently dropped.
6+
57
## 0.134.5
68

79
- Markdown agents now honor the YAML `name:` field and strip all filename extensions for the agent id (e.g. `foo.agent.md``foo`), matching Claude/OpenCode plugin conventions.

docs/config/agents.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,18 @@ Subagents can be configured in config or markdown and support/require these fiel
142142

143143
This is equivalent to `argsMatchers` in JSON config. Patterns on tools other than `eca__shell_command` are currently ignored.
144144

145+
!!! info "Claude-compatible tools list"
146+
147+
For Claude Code / OpenCode plugin compatibility, `tools` can also be given as a flat YAML list. It is treated as `byDefault: ask` plus the list as `allow`:
148+
149+
```yaml
150+
tools:
151+
- eca__read_file
152+
- eca__grep
153+
```
154+
155+
The entries must be ECA tool ids for the allowlist to take effect; unknown names are accepted but will not match any real tool.
156+
145157
!!! info "Tool call approval"
146158
147159
For more complex tool call approval, use toolCall via config

src/eca/features/agents.clj

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,29 +48,41 @@
4848
{}
4949
tool-entries)))
5050

51+
(defn ^:private normalize-tools
52+
"Coerces the YAML `tools:` value into the map form ECA expects.
53+
- Map form is returned as-is (ECA convention: byDefault/allow/ask/deny).
54+
- Sequential form (Claude convention: a flat list of allowed tool names)
55+
is normalized to {byDefault ask, allow <list>}.
56+
- Any other shape (string, number, malformed) is treated as absent."
57+
[tools]
58+
(cond
59+
(map? tools) tools
60+
(sequential? tools) {"byDefault" "ask" "allow" (vec tools)}
61+
:else nil))
62+
5163
(defn ^:private md->agent-config
5264
[{:keys [description mode model steps tools body inherit]}]
53-
(cond-> {}
54-
inherit (assoc :inherit (str inherit))
55-
description (assoc :description description)
56-
mode (assoc :mode (str mode))
57-
model (assoc :defaultModel (str model))
58-
steps (assoc :maxSteps (long steps))
59-
(seq body) (assoc :systemPrompt body)
60-
tools (assoc :toolCall
61-
(let [tools-map (if (map? tools) tools (into {} tools))]
62-
(cond-> {:approval {}}
63-
(get tools-map "byDefault")
64-
(assoc-in [:approval :byDefault] (get tools-map "byDefault"))
65-
66-
(get tools-map "allow")
67-
(assoc-in [:approval :allow] (tools-list->approval-map (get tools-map "allow")))
68-
69-
(get tools-map "deny")
70-
(assoc-in [:approval :deny] (tools-list->approval-map (get tools-map "deny")))
71-
72-
(get tools-map "ask")
73-
(assoc-in [:approval :ask] (tools-list->approval-map (get tools-map "ask"))))))))
65+
(let [tools-map (normalize-tools tools)]
66+
(cond-> {}
67+
inherit (assoc :inherit (str inherit))
68+
description (assoc :description description)
69+
mode (assoc :mode (str mode))
70+
model (assoc :defaultModel (str model))
71+
steps (assoc :maxSteps (long steps))
72+
(seq body) (assoc :systemPrompt body)
73+
tools-map (assoc :toolCall
74+
(cond-> {:approval {}}
75+
(get tools-map "byDefault")
76+
(assoc-in [:approval :byDefault] (get tools-map "byDefault"))
77+
78+
(get tools-map "allow")
79+
(assoc-in [:approval :allow] (tools-list->approval-map (get tools-map "allow")))
80+
81+
(get tools-map "deny")
82+
(assoc-in [:approval :deny] (tools-list->approval-map (get tools-map "deny")))
83+
84+
(get tools-map "ask")
85+
(assoc-in [:approval :ask] (tools-list->approval-map (get tools-map "ask"))))))))
7486

7587
(defn ^:private agent-name-from-frontmatter
7688
"Returns the agent id from YAML frontmatter `name:` when present and non-blank,

test/eca/features/agents_test.clj

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,53 @@
128128
config (#'agents/md->agent-config parsed)]
129129
(is (= "A primary agent" (:description config)))
130130
(is (= "Do work." (:systemPrompt config)))
131-
(is (nil? (:mode config))))))
131+
(is (nil? (:mode config)))))
132+
133+
(testing "tools as a YAML list normalizes to byDefault=ask + allow map (Claude form)"
134+
(let [md (str "---\n"
135+
"description: Reviewer\n"
136+
"tools:\n"
137+
" - read\n"
138+
" - search\n"
139+
" - agent\n"
140+
"---\n\n"
141+
"Body.")
142+
parsed (shared/parse-md md)
143+
config (#'agents/md->agent-config parsed)]
144+
(is (match? {:description "Reviewer"
145+
:systemPrompt "Body."
146+
:toolCall {:approval {:byDefault "ask"
147+
:allow {"read" {}
148+
"search" {}
149+
"agent" {}}}}}
150+
config))))
151+
152+
(testing "tools as a malformed string is ignored without crashing the agent"
153+
(let [config (#'agents/md->agent-config {:description "no tools" :tools "read"})]
154+
(is (= "no tools" (:description config)))
155+
(is (nil? (:toolCall config)))))
156+
157+
(testing "tools as a number is ignored without crashing the agent"
158+
(let [config (#'agents/md->agent-config {:description "no tools" :tools 42})]
159+
(is (= "no tools" (:description config)))
160+
(is (nil? (:toolCall config))))))
161+
162+
(deftest normalize-tools-test
163+
(testing "map form passes through unchanged"
164+
(is (= {"byDefault" "ask" "allow" ["read"]}
165+
(#'agents/normalize-tools {"byDefault" "ask" "allow" ["read"]}))))
166+
(testing "vector form is wrapped as byDefault=ask + allow"
167+
(is (= {"byDefault" "ask" "allow" ["read" "search"]}
168+
(#'agents/normalize-tools ["read" "search"]))))
169+
(testing "list form (clojure list) is also accepted"
170+
(is (= {"byDefault" "ask" "allow" ["read" "search"]}
171+
(#'agents/normalize-tools '("read" "search")))))
172+
(testing "nil returns nil"
173+
(is (nil? (#'agents/normalize-tools nil))))
174+
(testing "string returns nil (treated as malformed)"
175+
(is (nil? (#'agents/normalize-tools "read"))))
176+
(testing "number returns nil (treated as malformed)"
177+
(is (nil? (#'agents/normalize-tools 42)))))
132178

133179
(deftest md-agents-from-directory-test
134180
(let [tmp-dir (fs/create-temp-dir)
@@ -310,6 +356,33 @@
310356
(testing "handles filenames with multiple dots"
311357
(is (= "foo" (#'agents/agent-name-from-filename (fs/file "foo.bar.baz.md"))))))
312358

359+
(deftest claude-tools-list-loads-agent-test
360+
(let [tmp-dir (fs/create-temp-dir)
361+
agents-dir (fs/file tmp-dir "agents")]
362+
(try
363+
(fs/create-dirs agents-dir)
364+
;; Lucas's exact reproducer: tools as a YAML list (Claude convention).
365+
(spit (fs/file agents-dir "glp-engineer.agent.md")
366+
(str "---\n"
367+
"name: GLP-Reviewer\n"
368+
"description: \"Review any design document with DRC-style structured feedback and rubric scoring\"\n"
369+
"tools:\n"
370+
" - read\n"
371+
" - search\n"
372+
" - agent\n"
373+
"---\n\n"
374+
"You are a reviewer."))
375+
(let [result (#'agents/agent-md-file->agent (fs/file agents-dir "glp-engineer.agent.md"))]
376+
(testing "agent is loaded (not silently dropped by tools-list parse error)"
377+
(is (some? result)))
378+
(testing "agent id comes from YAML name"
379+
(is (= "glp-reviewer" (first result))))
380+
(testing "description is preserved"
381+
(is (= "Review any design document with DRC-style structured feedback and rubric scoring"
382+
(get-in (second result) [:description])))))
383+
(finally
384+
(fs/delete-tree tmp-dir)))))
385+
313386
(deftest agent-id-precedence-test
314387
(let [tmp-dir (fs/create-temp-dir)
315388
agents-dir (fs/file tmp-dir "agents")]

test/eca/features/plugins_test.clj

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,54 @@
313313
(is (= [{:path "/a/commands/cmd.md"}] (:commands result)))
314314
(is (= [{:path "/b/rules/rule.mdc"}] (:rules result))))))
315315

316+
(deftest plugin-agent-with-claude-tools-list-becomes-primary-test
317+
(let [tmp-dir (fs/create-temp-dir)
318+
source-dir (fs/file tmp-dir "repo")
319+
plugin-dir (fs/file source-dir "plugins" "test" "design-review")
320+
prev-plugin-components @config/plugin-components*
321+
prev-init-config @config/initialization-config*]
322+
(try
323+
(fs/create-dirs (fs/file source-dir ".eca-plugin"))
324+
(fs/create-dirs (fs/file plugin-dir "agents"))
325+
(spit (fs/file source-dir ".eca-plugin" "marketplace.json")
326+
(json/generate-string
327+
{:plugins [{:name "design-review"
328+
:description "Design review plugin"
329+
:source "./plugins/test/design-review"}]}))
330+
;; Lucas's exact reproducer: Claude-style frontmatter with tools-as-list.
331+
(spit (fs/file plugin-dir "agents" "glp-engineer.agent.md")
332+
(str "---\n"
333+
"name: GLP-Reviewer\n"
334+
"description: \"Review any design document with DRC-style structured feedback and rubric scoring\"\n"
335+
"tools:\n"
336+
" - read\n"
337+
" - search\n"
338+
" - agent\n"
339+
"---\n\n"
340+
"Role & Goal: review designs."))
341+
(let [resolved (plugins/resolve-all!
342+
{"local" {:source (str source-dir)}
343+
"install" ["design-review"]})]
344+
(testing "plugin discovery loads the Claude-style agent without dropping it"
345+
(is (contains? (:agents resolved) "glp-reviewer")))
346+
347+
(reset! config/plugin-components* resolved)
348+
(reset! config/initialization-config* {:pureConfig false})
349+
(let [final-config (#'config/all* {:workspace-folders []})
350+
primaries (set (config/primary-agent-names final-config))]
351+
(testing "tools list is normalized to byDefault=ask + allow"
352+
(is (match? {:approval {:byDefault "ask"
353+
:allow {"read" {}
354+
"search" {}
355+
"agent" {}}}}
356+
(get-in final-config [:agent "glp-reviewer" :toolCall]))))
357+
(testing "agent appears in primary-agent-names"
358+
(is (contains? primaries "glp-reviewer")))))
359+
(finally
360+
(reset! config/plugin-components* prev-plugin-components)
361+
(reset! config/initialization-config* prev-init-config)
362+
(fs/delete-tree tmp-dir)))))
363+
316364
(deftest plugin-agent-without-mode-becomes-primary-test
317365
(let [tmp-dir (fs/create-temp-dir)
318366
source-dir (fs/file tmp-dir "repo")

0 commit comments

Comments
 (0)