|
3 | 3 | [babashka.fs :as fs] |
4 | 4 | [babashka.process :as p] |
5 | 5 | [cheshire.core :as json] |
| 6 | + [eca.features.tools.shell :as f.tools.shell] |
| 7 | + [eca.features.tools.util :as tools.util] |
6 | 8 | [eca.logger :as logger] |
7 | | - [eca.shared :as shared] |
8 | | - [eca.features.tools.shell :as f.tools.shell])) |
| 9 | + [eca.shared :as shared])) |
9 | 10 |
|
10 | 11 | (def ^:private logger-tag "[HOOK]") |
11 | 12 |
|
|
35 | 36 | [output] |
36 | 37 | (when (and output (not-empty output)) |
37 | 38 | (try |
38 | | - (let [parsed (json/parse-string output true)] |
| 39 | + (let [parsed (json/parse-string output)] |
39 | 40 | (if (map? parsed) |
40 | 41 | parsed |
41 | 42 | (logger/debug logger-tag "Hook JSON output must result in map"))) |
|
70 | 71 | (not (get hook :runOnError false)) |
71 | 72 | (:error data))) |
72 | 73 |
|
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] |
74 | 132 | (let [hook-config-type (keyword (:type hook)) |
75 | 133 | hook-config-type (cond ;; legacy values |
76 | 134 | (= :prePrompt hook-config-type) :preRequest |
77 | 135 | (= :postPrompt hook-config-type) :postRequest |
78 | 136 | :else hook-config-type)] |
79 | 137 | (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)) |
84 | 140 | false |
85 | 141 |
|
86 | 142 | (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)) |
89 | 152 |
|
90 | 153 | :else |
91 | 154 | true))) |
|
145 | 208 | "Run hook of specified type if matches any config for that type" |
146 | 209 | [hook-type |
147 | 210 | data |
148 | | - {:keys [on-before-action on-after-action] |
| 211 | + {:keys [on-before-action on-after-action native-tools] |
149 | 212 | :or {on-before-action identity |
150 | 213 | on-after-action identity}} |
151 | 214 | db |
152 | 215 | config] |
153 | 216 | ;; Sort hooks by name to ensure deterministic execution order. |
154 | 217 | (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) |
156 | 219 | (vec |
157 | 220 | (map-indexed (fn [i action] |
158 | 221 | (let [id (str (random-uuid)) |
|
0 commit comments