Skip to content

Commit 7e4e4c7

Browse files
authored
Merge pull request #439 from editor-code-assistant/hook-matcher-args-selectors
Support selector-based hook matchers
2 parents f237607 + 9c98f3f commit 7e4e4c7

13 files changed

Lines changed: 541 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- MCP tools that return image content blocks (e.g. an MCP image-generation/edit server) now render those images in the chat UI as `ChatImageContent` and replay them back to the LLM as image inputs on follow-up turns when the model supports vision. Implemented for `openai-responses` (synthetic user-role `input_image` after the `function_call_output`) and `anthropic` (mixed text + image blocks inside `tool_result.content`). `openai-chat` and `ollama` continue to receive a text placeholder until a parallel pattern is implemented there.
66
- Bugfix: MCP tools without a `description` (which the MCP spec marks optional) no longer break Anthropic chat requests with `tools.<n>.custom.description: Input should be a valid string`. Missing/empty descriptions now fall back to the tool's `title`, then to a synthesized `MCP tool: <name>` string at the MCP boundary so all providers receive a non-null string.
7+
- Hook `matcher` now supports object form keyed by tool selectors with per-tool `argsMatchers`; legacy string regex matchers remain supported.
78

89
## 0.131.0
910

docs/config.json

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -755,9 +755,23 @@
755755
]
756756
},
757757
"matcher": {
758-
"type": "string",
759-
"description": "Regex pattern for matching server__tool-name. Only for *ToolCall hook types.",
760-
"markdownDescription": "Regex pattern for matching server__tool-name. Only for *ToolCall hook types."
758+
"description": "Matches *ToolCall hooks; unmatched hooks are skipped. String: legacy regex against server__tool-name. Object: tool selector map with optional argsMatchers.",
759+
"markdownDescription": "Matches `*ToolCall` hooks; unmatched hooks are skipped. **String**: legacy regex against `server__tool-name`. **Object**: tool selector map with optional `argsMatchers`.",
760+
"oneOf": [
761+
{
762+
"type": "string",
763+
"description": "Regex pattern for matching server__tool-name.",
764+
"markdownDescription": "Regex pattern for matching `server__tool-name`."
765+
},
766+
{
767+
"type": "object",
768+
"description": "Tool selector map. Keys follow toolApproval selectors: full tool name (server__tool-name), native ECA tool name, or server name. Empty rule {} matches without argument filtering.",
769+
"markdownDescription": "Tool selector map. Keys follow `toolApproval` selectors: full tool name (`server__tool-name`), native ECA tool name, or server name. Empty rule `{}` matches without argument filtering.",
770+
"additionalProperties": {
771+
"$ref": "#/definitions/hookMatcherEntry"
772+
}
773+
}
774+
]
761775
},
762776
"visible": {
763777
"type": "boolean",
@@ -1010,6 +1024,25 @@
10101024
},
10111025
"additionalProperties": false
10121026
},
1027+
"hookMatcherEntry": {
1028+
"type": "object",
1029+
"description": "Hook matcher rule. Use argsMatchers to require argument values to match Java regex patterns.",
1030+
"markdownDescription": "Hook matcher rule. Use `argsMatchers` to require argument values to match Java regex patterns.",
1031+
"properties": {
1032+
"argsMatchers": {
1033+
"type": "object",
1034+
"description": "Tool argument name to Java regex alternatives. Every listed argument must exist and match at least one pattern.",
1035+
"markdownDescription": "Tool argument name to Java regex alternatives. Every listed argument must exist and match at least one pattern.",
1036+
"additionalProperties": {
1037+
"type": "array",
1038+
"items": {
1039+
"type": "string"
1040+
}
1041+
}
1042+
}
1043+
},
1044+
"additionalProperties": false
1045+
},
10131046
"mcpServer": {
10141047
"type": "object",
10151048
"description": "MCP server configuration. Use 'url' for remote HTTP servers or 'command' for stdio servers.",

docs/config/hooks.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Hooks are shell actions that run before or after specific events, useful for not
2424

2525
## Hook Options
2626

27-
- **`matcher`**: Regex for `server__tool-name`, only for `*ToolCall` hooks.
27+
- **`matcher`**: For `*ToolCall` hooks. String = legacy regex for `server__tool-name`. Object = tool selector map with optional `argsMatchers`. Selectors follow tool approval: full tool name (`eca__write_file`), native ECA tool name (`write_file`), or server name. In `argsMatchers`, keys are tool argument names and values are arrays of regex alternatives for that argument. All listed arguments must match; multiple regexes for one argument are alternatives.
2828
- **`visible`**: Show hook execution in chat (default: `true`).
2929
- **`runOnError`**: For `postToolCall`, run even if tool errored (default: `false`).
3030

@@ -174,6 +174,26 @@ To reject a tool call, either output `{"approval": "deny"}` or exit with code `2
174174
}
175175
```
176176

177+
=== "Match tool arguments"
178+
179+
```javascript title="~/.config/eca/config.json"
180+
{
181+
"hooks": {
182+
"check-allium": {
183+
"type": "postToolCall",
184+
"matcher": {
185+
"eca__write_file": {
186+
"argsMatchers": {
187+
"path": [".*\\.allium$"]
188+
}
189+
}
190+
},
191+
"actions": [{"type": "shell", "file": "hooks/check-allium.sh"}]
192+
}
193+
}
194+
}
195+
```
196+
177197
=== "Notify when subagent finishes"
178198

179199
```javascript title="~/.config/eca/config.json"

src/eca/config.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,8 @@
452452
[:providers :ANY :models]
453453
[:providers :ANY :models :ANY :extraHeaders]
454454
[:providers :ANY :models :ANY :variants]
455+
[:hooks :ANY :matcher]
456+
[:hooks :ANY :matcher :ANY :argsMatchers]
455457
[:toolCall :approval :allow]
456458
[:toolCall :approval :allow :ANY :argsMatchers]
457459
[:toolCall :approval :ask]

src/eca/features/chat.clj

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,9 @@
282282
(defn ^:private update-pre-request-state
283283
"Pure function to compute new state from hook result."
284284
[{:keys [final-prompt additional-contexts stop?]} {:keys [parsed raw-output exit]} action-name]
285-
(let [replaced-prompt (:replacedPrompt parsed)
285+
(let [replaced-prompt (get parsed "replacedPrompt")
286286
additional-context (if parsed
287-
(:additionalContext parsed)
287+
(get parsed "additionalContext")
288288
raw-output)
289289
success? (= 0 exit)]
290290
{:final-prompt (if (and replaced-prompt success?)
@@ -295,7 +295,7 @@
295295
{:hook-name action-name :content additional-context})
296296
additional-contexts)
297297
:stop? (or stop?
298-
(false? (get parsed :continue true)))}))
298+
(false? (get parsed "continue" true)))}))
299299

300300
(defn ^:private run-pre-request-action!
301301
"Run a single preRequest hook action, updating the accumulator state.
@@ -326,7 +326,7 @@
326326
:all-messages (:all-messages state)})
327327
db)]
328328
(let [{:keys [parsed raw-output raw-error exit]} result
329-
should-continue? (get parsed :continue true)]
329+
should-continue? (get parsed "continue" true)]
330330
;; Notify after action
331331
(lifecycle/notify-after-hook-action! chat-ctx (merge result
332332
{:id id
@@ -338,7 +338,7 @@
338338
:error raw-error}))
339339
;; Check if hook wants to stop
340340
(when (false? should-continue?)
341-
(when-let [stop-reason (:stopReason parsed)]
341+
(when-let [stop-reason (get parsed "stopReason")]
342342
(lifecycle/send-content! chat-ctx :system {:type :text :text stop-reason}))
343343
(lifecycle/finish-chat-prompt! :idle chat-ctx))
344344
;; Update accumulator
@@ -1148,7 +1148,7 @@
11481148
config)
11491149
;; Collect additionalContext from all chatStart hooks and store
11501150
;; it as startup-context for this chat.
1151-
(when-let [additional-contexts (seq (keep #(get-in % [:parsed :additionalContext]) @hook-results*))]
1151+
(when-let [additional-contexts (seq (keep #(get-in % [:parsed "additionalContext"]) @hook-results*))]
11521152
(swap! db* assoc-in [:chats chat-id :startup-context]
11531153
(string/join "\n\n" additional-contexts)))
11541154
;; Mark chatStart as fired for this chat in this server run

src/eca/features/chat/lifecycle.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
(defn- format-hook-output
4040
"Format hook output for display, showing parsed JSON fields or raw output."
41-
[{:keys [systemMessage replacedPrompt additionalContext] :as parsed} raw-output]
41+
[{:strs [systemMessage replacedPrompt additionalContext] :as parsed} raw-output]
4242
(if parsed
4343
(cond-> (or systemMessage "Hook executed")
4444
replacedPrompt (str "\nReplacedPrompt: " (pr-str replacedPrompt))
@@ -54,7 +54,7 @@
5454
:id id})))
5555

5656
(defn notify-after-hook-action! [chat-ctx {:keys [id name parsed raw-output raw-error exit type visible?]}]
57-
(when (and visible? (not (:suppressOutput parsed)))
57+
(when (and visible? (not (get parsed "suppressOutput")))
5858
(send-content! chat-ctx :system
5959
{:type :hookActionFinished
6060
:action-type type

src/eca/features/chat/tool_calls.clj

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@
6565
"Run postToolCall hooks and append any additionalContext to the tool output."
6666
[db* chat-ctx tool-call-id event-data]
6767
(let [tool-call-state (get-tool-call-state @db* (:chat-id chat-ctx) tool-call-id)
68-
chat-id (:chat-id chat-ctx)]
68+
chat-id (:chat-id chat-ctx)
69+
native-tools (filter #(= :native (:origin %))
70+
(f.tools/all-tools chat-id (:agent chat-ctx) @db* (:config chat-ctx)))]
6971
(f.hooks/trigger-if-matches!
7072
:postToolCall
7173
(merge (f.hooks/chat-hook-data @db* chat-id (:agent chat-ctx))
@@ -79,13 +81,14 @@
7981
;; Always notify UI
8082
(lifecycle/notify-after-hook-action! chat-ctx result)
8183
;; If hook provided additionalContext, append as XML to the tool output
82-
(when-let [ac (:additionalContext parsed)]
84+
(when-let [ac (get parsed "additionalContext")]
8385
(append-post-tool-additional-context!
8486
(:db* chat-ctx)
8587
(:chat-id chat-ctx)
8688
tool-call-id
8789
name
88-
ac)))}
90+
ac)))
91+
:native-tools native-tools}
8992
@db*
9093
(:config chat-ctx))))
9194

@@ -524,17 +527,17 @@
524527
:hook-rejection-reason nil, :hook-continue true, :hook-stop-reason nil}"
525528
[acc result]
526529
(let [parsed (:parsed result)
527-
hook-approval (:approval parsed)
530+
hook-approval (get parsed "approval")
528531
exit-code-2? (= f.hooks/hook-rejection-exit-code (:exit result))]
529532
(cond-> (update acc :hook-results conj result)
530533
;; Handle rejection (exit code 2 or explicit deny)
531534
(or exit-code-2? (= "deny" hook-approval))
532535
(merge {:hook-rejected? true
533-
:hook-rejection-reason (or (:additionalContext parsed)
536+
:hook-rejection-reason (or (get parsed "additionalContext")
534537
(:raw-error result)
535538
"Tool call rejected by hook")
536-
:hook-continue (get parsed :continue true)
537-
:hook-stop-reason (:stopReason parsed)})
539+
:hook-continue (get parsed "continue" true)
540+
:hook-stop-reason (get parsed "stopReason")})
538541

539542
;; Handle approval override (allow/ask) when not exit-code-2
540543
(and hook-approval (not exit-code-2?))
@@ -562,6 +565,7 @@
562565
name (:name tool)
563566
server (:server tool)
564567
server-name (:name server)
568+
native-tools (filter #(= :native (:origin %)) all-tools)
565569

566570
;; 1. Determine approval (trust promotion handled inside f.tools/approval)
567571
approval (f.tools/approval all-tools tool arguments db config agent-name {:trust trust})
@@ -596,14 +600,15 @@
596600
{:on-before-action on-before-hook-action
597601
:on-after-action (fn [result]
598602
(on-after-hook-action result)
599-
(swap! hook-state* process-pre-tool-call-hook-result result))}
603+
(swap! hook-state* process-pre-tool-call-hook-result result))
604+
:native-tools native-tools}
600605
db
601606
config)
602607

603608
;; 3. Merge all updatedInput from hooks
604609
{:keys [hook-results approval-override hook-rejected?
605610
hook-rejection-reason hook-continue hook-stop-reason]} @hook-state*
606-
updated-inputs (keep #(get-in % [:parsed :updatedInput]) hook-results)
611+
updated-inputs (keep #(get-in % [:parsed "updatedInput"]) hook-results)
607612
final-arguments (if (not-empty updated-inputs)
608613
(reduce merge arguments updated-inputs)
609614
arguments)

src/eca/features/hooks.clj

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
[babashka.fs :as fs]
44
[babashka.process :as p]
55
[cheshire.core :as json]
6+
[eca.features.tools.shell :as f.tools.shell]
7+
[eca.features.tools.util :as tools.util]
68
[eca.logger :as logger]
7-
[eca.shared :as shared]
8-
[eca.features.tools.shell :as f.tools.shell]))
9+
[eca.shared :as shared]))
910

1011
(def ^:private logger-tag "[HOOK]")
1112

@@ -35,7 +36,7 @@
3536
[output]
3637
(when (and output (not-empty output))
3738
(try
38-
(let [parsed (json/parse-string output true)]
39+
(let [parsed (json/parse-string output)]
3940
(if (map? parsed)
4041
parsed
4142
(logger/debug logger-tag "Hook JSON output must result in map")))
@@ -70,22 +71,84 @@
7071
(not (get hook :runOnError false))
7172
(:error data)))
7273

73-
(defn ^:private hook-matches? [hook-type data hook]
74+
(defn ^:private parse-matcher-rules
75+
"Returns normalized rules for missing, string, or object hook matchers.
76+
String matchers stay legacy regex rules; object matcher keys become tool selector rules."
77+
[matcher]
78+
(cond
79+
(nil? matcher)
80+
[{:kind :regex :pattern ".*"}]
81+
82+
(string? matcher)
83+
[{:kind :regex :pattern matcher}]
84+
85+
(map? matcher)
86+
(->> matcher
87+
(keep (fn [[tool-selector config]]
88+
(if (map? config)
89+
{:kind :selector
90+
:selector (tools.util/selector->string tool-selector)
91+
:args-matchers (:argsMatchers config)}
92+
(logger/warn logger-tag "Ignoring non-map matcher entry for selector" {:selector tool-selector}))))
93+
vec)
94+
95+
:else
96+
[]))
97+
98+
(defn ^:private arg-value [tool-input arg-name]
99+
(when (map? tool-input)
100+
(when-let [arg-str (tools.util/selector->string arg-name)]
101+
(get tool-input arg-str))))
102+
103+
(defn ^:private regex-matches? [pattern value]
104+
(try
105+
(boolean (re-matches (re-pattern (str pattern)) (str value)))
106+
(catch Exception e
107+
(logger/warn logger-tag "Invalid hook matcher regex" {:pattern (str pattern)
108+
:error (.getMessage e)})
109+
false)))
110+
111+
(defn ^:private args-match?
112+
"Matches all configured argument rules.
113+
Patterns within a single argument are ORed; arguments are ANDed; missing arguments do not match."
114+
[args-matchers tool-input]
115+
(cond
116+
(or (nil? args-matchers)
117+
(and (map? args-matchers) (empty? args-matchers)))
118+
true
119+
120+
(not (map? args-matchers))
121+
false
122+
123+
:else
124+
(every? (fn [[arg-name matchers]]
125+
(let [value (arg-value tool-input arg-name)]
126+
(and (some? value)
127+
(sequential? matchers)
128+
(some #(regex-matches? % value) matchers))))
129+
args-matchers)))
130+
131+
(defn ^:private hook-matches? [hook-type data hook native-tools]
74132
(let [hook-config-type (keyword (:type hook))
75133
hook-config-type (cond ;; legacy values
76134
(= :prePrompt hook-config-type) :preRequest
77135
(= :postPrompt hook-config-type) :postRequest
78136
:else hook-config-type)]
79137
(cond
80-
(not= hook-type hook-config-type)
81-
false
82-
83-
(should-skip-on-error? hook-type hook data)
138+
(or (not= hook-type hook-config-type)
139+
(should-skip-on-error? hook-type hook data))
84140
false
85141

86142
(contains? #{:preToolCall :postToolCall} hook-type)
87-
(re-matches (re-pattern (or (:matcher hook) ".*"))
88-
(str (:server data) "__" (:tool-name data)))
143+
(let [rules (parse-matcher-rules (:matcher hook))
144+
full-name (str (:server data) "__" (:tool-name data))]
145+
(some (fn [{:keys [kind pattern selector args-matchers]}]
146+
(case kind
147+
:regex (regex-matches? pattern full-name)
148+
:selector (and (tools.util/tool-selector-matches? selector (:server data) (:tool-name data) native-tools)
149+
(args-match? args-matchers (:tool-input data)))
150+
false))
151+
rules))
89152

90153
:else
91154
true)))
@@ -145,14 +208,14 @@
145208
"Run hook of specified type if matches any config for that type"
146209
[hook-type
147210
data
148-
{:keys [on-before-action on-after-action]
211+
{:keys [on-before-action on-after-action native-tools]
149212
:or {on-before-action identity
150213
on-after-action identity}}
151214
db
152215
config]
153216
;; Sort hooks by name to ensure deterministic execution order.
154217
(doseq [[name hook] (sort-by key (:hooks config))]
155-
(when (hook-matches? hook-type data hook)
218+
(when (hook-matches? hook-type data hook native-tools)
156219
(vec
157220
(map-indexed (fn [i action]
158221
(let [id (str (random-uuid))

0 commit comments

Comments
 (0)