Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- Bugfix: avoid `Divide by zero` crash in chat auto-compact when models.dev reports `0` for a model's context/output limits (e.g. `openai/chatgpt-image-latest`); such limits are now normalized to `nil` and `auto-compact?` skips models without a known positive context window.
- Bugfix: image edit follow-up turns no longer fail on the OpenAI Responses API when prior generations are replayed; generated images are now persisted under a dedicated `image_generation_call` history role and replayed as a user-role `input_image` data URL across providers.

- Support regex patterns in markdown agent tool entries (e.g. `eca__shell_command(npm run .*)`) for fine-grained tool approval, currently limited to `eca__shell_command`.

## 0.130.1

- Add configurable skill paths and recursive directory loading for configured rules, commands, and skills; local skills are also discovered from `.agents/skills`. #423
Expand Down
14 changes: 14 additions & 0 deletions docs/config/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,20 @@ Subagents can be configured in config or markdown and support/require these fiel
You should run sleep 1 and return "I slept 1 second"
```

!!! info "Pattern-based tool approval in markdown"

You can append a regex pattern in parentheses after a tool name to restrict approval to calls matching the pattern. Currently only `eca__shell_command` supports this — the pattern is matched against its `command` argument. Multiple entries for the same tool are automatically merged.

```yaml
tools:
allow:
- eca__shell_command(npm run .*)
- eca__shell_command(git diff(\s+.*)?)
- eca__read_file
```

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

!!! info "Tool call approval"

For more complex tool call approval, use toolCall via config
Expand Down
35 changes: 32 additions & 3 deletions src/eca/features/agents.clj
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,39 @@

(def ^:private logger-tag "[AGENTS-MD]")

(def ^:private tool-arg-name
"Maps tool names to the argument name used for regex pattern matching in argsMatchers.
Only tools that support pattern-based approval need an entry here."
{"eca__shell_command" "command"})

(defn ^:private parse-tool-entry
"Parses a tool entry string into [tool-name config].
Plain names like 'eca__read_file' -> ['eca__read_file' {}]
Pattern entries like 'eca__shell_command(npm run .*)' -> ['eca__shell_command' {:argsMatchers {'command' ['npm run .*']}}]"
[entry]
(let [s (str entry)]
(if-let [[_ tool-name pattern] (re-matches #"(.+?)\((.+)\)" s)]
(if-let [arg-name (get tool-arg-name tool-name)]
[tool-name {:argsMatchers {arg-name [pattern]}}]
(do (logger/warn logger-tag (format "Tool '%s' has pattern '%s' but no arg-name mapping in tool-arg-name; pattern will be ignored" tool-name pattern))
[tool-name {}]))
[s {}])))

(defn ^:private tools-list->approval-map
[tool-names]
(when (seq tool-names)
(into {} (map (fn [name] [(str name) {}]) tool-names))))
[tool-entries]
(when (seq tool-entries)
(reduce
(fn [acc entry]
(let [[tool-name config] (parse-tool-entry entry)]
(if (contains? acc tool-name)
;; Merge argsMatchers patterns for repeated tool entries
(update-in acc [tool-name :argsMatchers]
(fn [existing new-matchers]
(merge-with into existing new-matchers))
(:argsMatchers config))
(assoc acc tool-name config))))
{}
tool-entries)))

(defn ^:private md->agent-config
[{:keys [description mode model steps tools body inherit]}]
Expand Down
23 changes: 23 additions & 0 deletions test/eca/features/agents_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@
:deny {"foo" {}}}}}
config))))

(testing "tool entries with regex patterns"
(let [parsed {:tools {"byDefault" "ask"
"allow" ["eca__shell_command(npm run .*)"
"eca__shell_command(git commit .*)"
"eca__read_file"]}}
config (#'agents/md->agent-config parsed)]
(is (match? {:toolCall {:approval {:byDefault "ask"
:allow {"eca__shell_command" {:argsMatchers {"command" ["npm run .*" "git commit .*"]}}
"eca__read_file" {}}}}}
config))))

(testing "tool entry with pattern for unknown tool arg (no tool-arg-name entry)"
(let [parsed {:tools {"allow" ["eca__read_file(/tmp/.*)"]}}
config (#'agents/md->agent-config parsed)]
(is (match? {:toolCall {:approval {:allow {"eca__read_file" {}}}}}
config))))

(testing "single tool with pattern"
(let [parsed {:tools {"allow" ["eca__shell_command(git diff(\\s+.*)?)"]}}
config (#'agents/md->agent-config parsed)]
(is (match? {:toolCall {:approval {:allow {"eca__shell_command" {:argsMatchers {"command" ["git diff(\\s+.*)?"]}}}}}}
config))))

(testing "minimal agent with only description and body"
(let [parsed (shared/parse-md "---\ndescription: Simple agent\nmode: subagent\n---\n\nDo stuff")
config (#'agents/md->agent-config parsed)]
Expand Down