Skip to content

Commit 9c98f3f

Browse files
committed
Support selector-based hook matchers
Add object-form hook matchers keyed by tool selectors, with optional per-tool argsMatchers for preToolCall and postToolCall hooks. Keep legacy string regex matchers working for backwards compatibility. Preserve hook matcher selector and argument keys during config normalization, share selector matching with tool approval, and update docs/schema/tests for the new matcher shape.
1 parent f237607 commit 9c98f3f

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)