|
1 | 1 | (ns eca.features.hooks |
2 | 2 | (:require |
| 3 | + [babashka.fs :as fs] |
3 | 4 | [babashka.process :as p] |
4 | 5 | [cheshire.core :as json] |
5 | 6 | [eca.logger :as logger] |
6 | | - [eca.shared :as shared])) |
| 7 | + [eca.shared :as shared] |
| 8 | + [eca.features.tools.shell :as f.tools.shell])) |
7 | 9 |
|
8 | 10 | (def ^:private logger-tag "[HOOK]") |
9 | 11 |
|
10 | | -(defn ^:private hook-matches? [type data hook] |
| 12 | +(def ^:const hook-rejection-exit-code 2) |
| 13 | + |
| 14 | +(def ^:const default-hook-timeout-ms 30000) |
| 15 | + |
| 16 | +(defn base-hook-data |
| 17 | + "Returns common fields for ALL hooks (session and chat hooks). |
| 18 | + These fields are present in every hook type." |
| 19 | + [db] |
| 20 | + {:workspaces (shared/get-workspaces db) |
| 21 | + :db-cache-path (shared/db-cache-path db)}) |
| 22 | + |
| 23 | +(defn chat-hook-data |
| 24 | + "Returns common fields for CHAT-RELATED hooks. |
| 25 | + Includes base fields plus chat-specific fields (chat-id, behavior). |
| 26 | + Use this for: preRequest, postRequest, preToolCall, postToolCall, chatStart, chatEnd." |
| 27 | + [db chat-id behavior] |
| 28 | + (merge (base-hook-data db) |
| 29 | + {:chat-id chat-id |
| 30 | + :behavior behavior})) |
| 31 | + |
| 32 | +(defn ^:private parse-hook-json |
| 33 | + "Attempts to parse hook output as JSON. Returns parsed map if successful, nil otherwise." |
| 34 | + [output] |
| 35 | + (when (and output (not-empty output)) |
| 36 | + (try |
| 37 | + (let [parsed (json/parse-string output true)] |
| 38 | + (if (map? parsed) |
| 39 | + parsed |
| 40 | + (logger/debug logger-tag "Hook JSON output must result in map"))) |
| 41 | + (catch Exception e |
| 42 | + (logger/debug logger-tag "Hook output is not valid JSON, treating as plain text" |
| 43 | + {:output output :error (.getMessage e)}) |
| 44 | + nil)))) |
| 45 | + |
| 46 | +(defn run-shell-cmd [opts] |
| 47 | + (try |
| 48 | + (let [timeout-ms (or (:timeout opts) default-hook-timeout-ms) |
| 49 | + proc (f.tools.shell/start-shell-process! opts) |
| 50 | + result (deref proc timeout-ms ::timeout)] |
| 51 | + (if (= result ::timeout) |
| 52 | + (do |
| 53 | + (logger/warn logger-tag "Hook timed out" {:timeout-ms timeout-ms}) |
| 54 | + (p/destroy-tree proc) |
| 55 | + {:exit 1 :out nil :err (format "Hook timed out after %d seconds" (/ timeout-ms 1000))}) |
| 56 | + {:exit (:exit result) |
| 57 | + :out (:out result) |
| 58 | + :err (:err result)})) |
| 59 | + (catch Exception e |
| 60 | + (let [msg (or (.getMessage e) "Caught an Exception during execution of hook")] |
| 61 | + (logger/warn logger-tag "Got an Exception during execution" {:message msg}) |
| 62 | + {:exit 1 :err msg})))) |
| 63 | + |
| 64 | +(defn ^:private should-skip-on-error? |
| 65 | + "Check if postToolCall hook should be skipped when tool errors. |
| 66 | + By default, postToolCall hooks only run on success unless runOnError is true." |
| 67 | + [type hook data] |
| 68 | + (and (= type :postToolCall) |
| 69 | + (not (get hook :runOnError false)) |
| 70 | + (:error data))) |
| 71 | + |
| 72 | +(defn ^:private hook-matches? [hook-type data hook] |
11 | 73 | (let [hook-config-type (keyword (:type hook)) |
12 | 74 | hook-config-type (cond ;; legacy values |
13 | 75 | (= :prePrompt hook-config-type) :preRequest |
14 | 76 | (= :postPrompt hook-config-type) :postRequest |
15 | 77 | :else hook-config-type)] |
16 | 78 | (cond |
17 | | - (not= type hook-config-type) |
| 79 | + (not= hook-type hook-config-type) |
18 | 80 | false |
19 | 81 |
|
20 | | - (contains? #{:preToolCall :postToolCall} type) |
| 82 | + (should-skip-on-error? hook-type hook data) |
| 83 | + false |
| 84 | + |
| 85 | + (contains? #{:preToolCall :postToolCall} hook-type) |
21 | 86 | (re-matches (re-pattern (or (:matcher hook) ".*")) |
22 | 87 | (str (:server data) "__" (:tool-name data))) |
23 | 88 |
|
24 | 89 | :else |
25 | 90 | true))) |
26 | 91 |
|
27 | | -(defn ^:private run-hook-action! [action name data db] |
| 92 | +(defn ^:private run-and-parse-output! |
| 93 | + "Run shell command and return parsed result map." |
| 94 | + [opts] |
| 95 | + (let [{:keys [exit out err]} (run-shell-cmd opts) |
| 96 | + raw-output (not-empty out) |
| 97 | + raw-error (not-empty err)] |
| 98 | + {:exit exit |
| 99 | + :raw-output raw-output |
| 100 | + :raw-error raw-error |
| 101 | + :parsed (parse-hook-json raw-output)})) |
| 102 | + |
| 103 | +(defn run-hook-action! |
| 104 | + "Execute a single hook action. Supported hook types: |
| 105 | + - :sessionStart, :sessionEnd (session lifecycle) |
| 106 | + - :chatStart, :chatEnd (chat lifecycle) |
| 107 | + - :preRequest, :postRequest (prompt lifecycle) |
| 108 | + - :preToolCall, :postToolCall (tool lifecycle) |
| 109 | +
|
| 110 | + Returns map with :exit, :raw-output, :raw-error, :parsed" |
| 111 | + [action name hook-type data db] |
28 | 112 | (case (:type action) |
29 | 113 | "shell" (let [cwd (some-> (:workspace-folders db) |
30 | 114 | first |
31 | 115 | :uri |
32 | 116 | shared/uri->filename) |
33 | 117 | shell (:shell action) |
34 | | - input (json/generate-string (merge {:hook-name name} data))] |
35 | | - (logger/info logger-tag (format "Running hook '%s' shell '%s' with input '%s'" name shell input)) |
36 | | - (let [{:keys [exit out err]} (p/sh {:dir cwd} |
37 | | - "bash" "-c" shell "--" input)] |
38 | | - [exit (not-empty out) (not-empty err)])) |
39 | | - (logger/warn logger-tag (format "Unknown hook action %s for %s" (:type action) name)))) |
| 118 | + file (:file action) |
| 119 | + ;; Convert to snake_case for bash/shell conventions |
| 120 | + ;; Nested keys (e.g. tool_input/tool_response): kebab-case (matches LLM format) |
| 121 | + input (json/generate-string (shared/map->snake-cased-map |
| 122 | + (merge {:hook-name name :hook-type hook-type} data)))] |
| 123 | + (cond |
| 124 | + (and shell file) |
| 125 | + (logger/error logger-tag (format "Hook '%s' has both 'shell' and 'file' - must have exactly one" name)) |
| 126 | + |
| 127 | + (and (not shell) (not file)) |
| 128 | + (logger/error logger-tag (format "Hook '%s' missing both 'shell' and 'file' - must have one" name)) |
| 129 | + |
| 130 | + (nil? cwd) |
| 131 | + (logger/error logger-tag (format "Hook '%s' cannot run: no workspace folders configured" name)) |
| 132 | + |
| 133 | + shell |
| 134 | + (do (logger/debug logger-tag (format "Running hook '%s' inline shell '%s' with input '%s'" name shell input)) |
| 135 | + (run-and-parse-output! {:cwd cwd :input input :script shell :timeout (:timeout action)})) |
| 136 | + |
| 137 | + file |
| 138 | + (do (logger/debug logger-tag (format "Running hook '%s' file '%s' with input '%s'" name file input)) |
| 139 | + (run-and-parse-output! {:cwd cwd :input input :file (str (fs/expand-home file)) :timeout (:timeout action)})))) |
| 140 | + |
| 141 | + (logger/warn logger-tag (format "Unknown hook action type '%s' for %s" (:type action) name)))) |
40 | 142 |
|
41 | 143 | (defn trigger-if-matches! |
42 | 144 | "Run hook of specified type if matches any config for that type" |
43 | | - [type |
| 145 | + [hook-type |
44 | 146 | data |
45 | 147 | {:keys [on-before-action on-after-action] |
46 | 148 | :or {on-before-action identity |
47 | 149 | on-after-action identity}} |
48 | 150 | db |
49 | 151 | config] |
50 | | - (doseq [[name hook] (:hooks config)] |
51 | | - (when (hook-matches? type data hook) |
| 152 | + ;; Sort hooks by name to ensure deterministic execution order. |
| 153 | + (doseq [[name hook] (sort-by key (:hooks config))] |
| 154 | + (when (hook-matches? hook-type data hook) |
52 | 155 | (vec |
53 | 156 | (map-indexed (fn [i action] |
54 | 157 | (let [id (str (random-uuid)) |
55 | | - type (:type action) |
56 | | - name (if (> 1 (count (:actions hook))) |
| 158 | + action-type (:type action) |
| 159 | + name (if (> (count (:actions hook)) 1) |
57 | 160 | (str name "-" (inc i)) |
58 | 161 | name) |
59 | 162 | visible? (get hook :visible true)] |
60 | 163 | (on-before-action {:id id |
61 | 164 | :visible? visible? |
62 | 165 | :name name}) |
63 | | - (if-let [[status output error] (run-hook-action! action name data db)] |
64 | | - (on-after-action {:id id |
65 | | - :name name |
66 | | - :type type |
67 | | - :visible? visible? |
68 | | - :status status |
69 | | - :output output |
70 | | - :error error}) |
| 166 | + (if-let [result (run-hook-action! action name hook-type data db)] |
| 167 | + (on-after-action (merge result |
| 168 | + {:id id |
| 169 | + :name name |
| 170 | + :type action-type |
| 171 | + :visible? visible?})) |
71 | 172 | (on-after-action {:id id |
72 | 173 | :name name |
73 | 174 | :visible? visible? |
74 | | - :type type |
75 | | - :status -1})))) |
| 175 | + :type action-type |
| 176 | + :exit 1})))) |
76 | 177 | (:actions hook)))))) |
0 commit comments