Skip to content

Commit f8268df

Browse files
committed
feat: implement advanced lifecycle hooks
Key changes: - New Hook Types: `sessionStart`, `sessionEnd`, `chatStart`, `chatEnd` for better lifecycle management. - Enhanced `preRequest`: Supports prompt rewriting, context injection, and request blocking. - Enhanced `preToolCall`: Allows argument modification, approval overrides (allow/ask/deny), and tool blocking via exit codes or JSON. - New `postToolCall`: Provides access to tool responses and errors, allowing for post-execution context injection. - JSON Protocol: Hooks now communicate via structured JSON on stdin/stdout for robust data exchange. - Integration: Updates chat loop and tool state machine to respect hook decisions.
1 parent 5715dea commit f8268df

File tree

13 files changed

+1684
-566
lines changed

13 files changed

+1684
-566
lines changed

integration-test/entrypoint.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
[babashka.process :refer [shell]]
44
[clojure.test :as t]
55
[integration.eca :as eca]
6+
[integration.chat.hooks-test]
67
[llm-mock.server :as llm-mock.server]))
78

89
(def namespaces
910
'[integration.initialize-test
11+
integration.chat.hooks-test
1012
integration.chat.openai-test
1113
integration.chat.anthropic-test
1214
integration.chat.github-copilot-test

integration-test/integration/chat/hooks_test.clj

Lines changed: 382 additions & 0 deletions
Large diffs are not rendered by default.

src/eca/features/chat.clj

Lines changed: 670 additions & 388 deletions
Large diffs are not rendered by default.

src/eca/features/hooks.clj

Lines changed: 127 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,177 @@
11
(ns eca.features.hooks
22
(:require
3+
[babashka.fs :as fs]
34
[babashka.process :as p]
45
[cheshire.core :as json]
56
[eca.logger :as logger]
6-
[eca.shared :as shared]))
7+
[eca.shared :as shared]
8+
[eca.features.tools.shell :as f.tools.shell]))
79

810
(def ^:private logger-tag "[HOOK]")
911

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]
1173
(let [hook-config-type (keyword (:type hook))
1274
hook-config-type (cond ;; legacy values
1375
(= :prePrompt hook-config-type) :preRequest
1476
(= :postPrompt hook-config-type) :postRequest
1577
:else hook-config-type)]
1678
(cond
17-
(not= type hook-config-type)
79+
(not= hook-type hook-config-type)
1880
false
1981

20-
(contains? #{:preToolCall :postToolCall} type)
82+
(should-skip-on-error? hook-type hook data)
83+
false
84+
85+
(contains? #{:preToolCall :postToolCall} hook-type)
2186
(re-matches (re-pattern (or (:matcher hook) ".*"))
2287
(str (:server data) "__" (:tool-name data)))
2388

2489
:else
2590
true)))
2691

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]
28112
(case (:type action)
29113
"shell" (let [cwd (some-> (:workspace-folders db)
30114
first
31115
:uri
32116
shared/uri->filename)
33117
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))))
40142

41143
(defn trigger-if-matches!
42144
"Run hook of specified type if matches any config for that type"
43-
[type
145+
[hook-type
44146
data
45147
{:keys [on-before-action on-after-action]
46148
:or {on-before-action identity
47149
on-after-action identity}}
48150
db
49151
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)
52155
(vec
53156
(map-indexed (fn [i action]
54157
(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)
57160
(str name "-" (inc i))
58161
name)
59162
visible? (get hook :visible true)]
60163
(on-before-action {:id id
61164
:visible? visible?
62165
: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?}))
71172
(on-after-action {:id id
72173
:name name
73174
:visible? visible?
74-
:type type
75-
:status -1}))))
175+
:type action-type
176+
:exit 1}))))
76177
(:actions hook))))))

src/eca/features/prompt.clj

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
:else
6060
(load-builtin-prompt "agent_behavior.md"))))
6161

62-
(defn contexts-str [refined-contexts repo-map*]
62+
(defn contexts-str [refined-contexts repo-map* startup-ctx]
6363
(multi-str
6464
"<contexts description=\"User-Provided. This content is current and accurate. Treat this as sufficient context for answering the query.\">"
6565
""
@@ -86,9 +86,12 @@
8686
"")))
8787
""
8888
refined-contexts)
89+
;; TODO - should be refined contexts?
90+
(when startup-ctx
91+
(str "\n<additionalContext from=\"chatStart\">\n" startup-ctx "\n</additionalContext>\n\n"))
8992
"</contexts>"))
9093

91-
(defn build-chat-instructions [refined-contexts rules repo-map* behavior config db]
94+
(defn build-chat-instructions [refined-contexts rules repo-map* behavior config chat-id db]
9295
(multi-str
9396
(eca-chat-prompt behavior config)
9497
(when (seq rules)
@@ -105,7 +108,7 @@
105108
(when (seq refined-contexts)
106109
["## Contexts"
107110
""
108-
(contexts-str refined-contexts repo-map*)])
111+
(contexts-str refined-contexts repo-map* (get-in db [:chats chat-id :startup-context]))])
109112
""
110113
(replace-vars
111114
(load-builtin-prompt "additional_system_info.md")

src/eca/features/tools/shell.clj

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,40 @@
1515
(def ^:private default-timeout 60000)
1616
(def ^:private max-timeout (* 60000 10))
1717

18+
(defn start-shell-process!
19+
"Start a shell process, returning the process object for deref/management.
20+
21+
Options:
22+
- :cwd Working directory (required)
23+
- :script Inline script string (mutually exclusive with :file)
24+
- :file Script file path (mutually exclusive with :script)
25+
- :input String to pass as stdin (optional)
26+
27+
Returns: babashka.process process object (deref-able)"
28+
[{:keys [cwd script file input]}]
29+
{:pre [(some? cwd)
30+
(or (some? script) (some? file))
31+
(not (and script file))]}
32+
(let [win? (string/starts-with? (System/getProperty "os.name") "Windows")
33+
cmd (cond
34+
(and win? file)
35+
["powershell.exe" "-ExecutionPolicy" "Bypass" "-File" file]
36+
37+
(and win? script)
38+
["powershell.exe" "-NoProfile" "-Command" script]
39+
40+
file
41+
["bash" file]
42+
43+
:else
44+
["bash" "-c" script])]
45+
(p/process (cond-> {:cmd cmd
46+
:dir cwd
47+
:out :string
48+
:err :string
49+
:continue true}
50+
input (assoc :in input)))))
51+
1852
(defn ^:private shell-command [arguments {:keys [db tool-call-id call-state-fn state-transition-fn]}]
1953
(let [command-args (get arguments "command")
2054
user-work-dir (get arguments "working_directory")
@@ -30,11 +64,8 @@
3064
_ (logger/debug logger-tag "Running command:" command-args)
3165
result (try
3266
(if-let [proc (when-not (= :stopping (:status (call-state-fn)))
33-
(p/process {:dir work-dir
34-
:out :string
35-
:err :string
36-
:timeout timeout
37-
:continue true} "bash -c" command-args))]
67+
(start-shell-process! {:cwd work-dir
68+
:script command-args}))]
3869
(do
3970
(state-transition-fn :resources-created {:resources {:process proc}})
4071
(try (deref proc

src/eca/handlers.clj

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[eca.db :as db]
55
[eca.features.chat :as f.chat]
66
[eca.features.completion :as f.completion]
7+
[eca.features.hooks :as f.hooks]
78
[eca.features.login :as f.login]
89
[eca.features.rewrite :as f.rewrite]
910
[eca.features.tools :as f.tools]
@@ -73,10 +74,27 @@
7374
error)}}))
7475
(config/listen-for-changes! db*))
7576
(future
76-
(f.tools/init-servers! db* messenger config metrics)))
77-
78-
(defn shutdown [{:keys [db* metrics]}]
77+
(f.tools/init-servers! db* messenger config metrics))
78+
;; Trigger sessionStart hook after initialization
79+
(f.hooks/trigger-if-matches! :sessionStart
80+
(f.hooks/base-hook-data @db*)
81+
{}
82+
@db*
83+
config))
84+
85+
(defn shutdown [{:keys [db* config metrics]}]
7986
(metrics/task metrics :eca/shutdown
87+
;; 1. Save cache BEFORE hook so db-cache-path contains current state
88+
(db/update-workspaces-cache! @db* metrics)
89+
90+
;; 2. Trigger sessionEnd hook
91+
(f.hooks/trigger-if-matches! :sessionEnd
92+
(f.hooks/base-hook-data @db*)
93+
{}
94+
@db*
95+
config)
96+
97+
;; 3. Then shutdown
8098
(f.mcp/shutdown! db*)
8199
(swap! db* assoc :stopping true)
82100
nil))
@@ -111,9 +129,9 @@
111129
(metrics/task metrics :eca/chat-prompt-stop
112130
(f.chat/prompt-stop params db* messenger metrics)))
113131

114-
(defn chat-delete [{:keys [db* metrics]} params]
132+
(defn chat-delete [{:keys [db* config metrics]} params]
115133
(metrics/task metrics :eca/chat-delete
116-
(f.chat/delete-chat params db* metrics)
134+
(f.chat/delete-chat params db* config metrics)
117135
{}))
118136

119137
(defn chat-rollback [{:keys [db* metrics messenger]} params]

src/eca/shared.clj

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
[clojure.core.memoize :as memoize]
55
[clojure.java.io :as io]
66
[clojure.string :as string]
7-
[clojure.walk :as walk])
7+
[clojure.walk :as walk]
8+
[eca.cache :as cache])
89
(:import
910
[java.net URI]
1011
[java.nio.file Paths]
@@ -143,6 +144,12 @@
143144
x))
144145
m)))
145146

147+
(defn map->snake-cased-map
148+
"Converts top-level keyword keys to snake_case strings.
149+
Used for hook script inputs to follow shell/bash conventions."
150+
[m]
151+
(update-keys m #(if (keyword %) (csk/->snake_case %) %)))
152+
146153
(defn obfuscate
147154
"Obfuscate all but first `preserve-num` and last `preserve-num` characters of a string.
148155
If the string is 4 characters or less, obfuscate all characters.
@@ -214,3 +221,16 @@
214221
(deliver p# e#)))))]
215222
(.start t#)
216223
p#)))
224+
225+
(defn get-workspaces
226+
"Returns a vector of all workspace folder paths.
227+
Returns nil if no workspace folders are configured."
228+
[db]
229+
(when-let [folders (seq (:workspace-folders db))]
230+
(mapv (comp uri->filename :uri) folders)))
231+
232+
(defn db-cache-path
233+
"Returns the absolute path to the workspace-specific DB cache file as a string.
234+
Used by hooks to access the cached database."
235+
[db]
236+
(str (cache/workspace-cache-file (:workspace-folders db) "db.transit.json" uri->filename)))

0 commit comments

Comments
 (0)