Skip to content

Commit b6574a3

Browse files
committed
Merge branch 'hooks'
2 parents 383735d + 5370032 commit b6574a3

File tree

9 files changed

+390
-14
lines changed

9 files changed

+390
-14
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Add hooks support. #43
6+
57
## 0.69.1
68

79
- Fix regression on models with no extraPayload.

docs/configuration.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,60 @@ ECA allows to totally customize the prompt sent to LLM via the `behavior` config
352352
}
353353
}
354354
```
355+
356+
## Hooks
357+
358+
Hooks are actions that can run before or after an specific event, useful to notify after prompt finished or to block a tool call doing some check in a script.
359+
360+
Allowed hook types:
361+
362+
- `prePrompt`: Run before prompt is sent to LLM, if a hook output is provided, append to user prompt.
363+
- `postPrompt`: Run after prompt is finished, when chat come back to idle state.
364+
- `preToolCall`: Run before a tool is called, if a hook exit with status `2`, reject the tool call.
365+
- `postToolCall`: Run after a tool was called.
366+
367+
__Input__: Hooks will receive input as json with information from that event, like tool name, args or user prompt.
368+
__Output__: All hook actions allow printing output (stdout) and errors (stderr) which will be shown in chat.
369+
__Matcher__: Specify whether to apply this hook checking a regex applying to `mcp__tool-name`, applicable only for `*ToolCall` hooks.
370+
371+
Examples:
372+
373+
=== "Notify after prompt finish"
374+
375+
```javascript
376+
{
377+
"hooks": {
378+
"notify-me": {
379+
"type": "postPrompt",
380+
"actions": [
381+
{
382+
"type": "shell",
383+
"shell": "notify-send \"Hey, prompt finished!\""
384+
}
385+
]
386+
}
387+
}
388+
}
389+
```
390+
391+
=== "Block specific tool call"
392+
393+
```javascript
394+
{
395+
"hooks": {
396+
"notify-me": {
397+
"type": "preToolCall",
398+
"matcher": "my-mcp__some-tool",
399+
"actions": [
400+
{
401+
"type": "shell",
402+
"shell": "echo \"We should not run this tool bro!\" >&2 && exit 2"
403+
}
404+
]
405+
}
406+
}
407+
}
408+
```
355409

356410
## Opentelemetry integration
357411

@@ -387,6 +441,15 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
387441
}};
388442
}};
389443
defaultModel?: string;
444+
hooks?: {[key: string]: {
445+
type: 'preToolCall' | 'postToolCall' | 'preToolCallApproval' | 'prePrompt' | 'postPrompt';
446+
matcher: string;
447+
actions: {
448+
type: 'shell';
449+
shell: string;
450+
}[];
451+
};
452+
};
390453
rules?: [{path: string;}];
391454
commands?: [{path: string;}];
392455
behavior?: {[key: string]: {
@@ -464,6 +527,7 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
464527
"ollama": {"url": "http://localhost:11434"}
465528
},
466529
"defaultModel": null, // let ECA decides the default model.
530+
"hooks": {},
467531
"rules" : [],
468532
"commands" : [],
469533
"disabledTools": [],

docs/features.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ The built-in commands are:
111111

112112
It's possible to configure custom command prompts, for more details check [its configuration](./configuration.md#custom-command-prompts)
113113

114-
### Login
114+
#### Login
115115

116116
It's possible to login to some providers using `/login` command, ECA will ask and give instructions on how to authenticate in the chosen provider and save the login info globally in its cache `~/.cache/eca/db.transit.json`.
117117

@@ -120,6 +120,14 @@ Current supported providers with login:
120120
- `anthropic`: with options to login to Claude Max/Pro or create API keys.
121121
- `github-copilot`: via Github oauth.
122122

123+
### Hooks
124+
125+
Hooks are actions that can run before or after an specific event, useful to notify after prompt finished or to block a tool call doing some check in a script.
126+
127+
![](./images/features/hooks.png)
128+
129+
For more details, check [hooks configuration](./configuration.md#hooks)
130+
123131
## OpenTelemetry integration
124132

125133
ECA has support for [OpenTelemetry](https://opentelemetry.io/)(otlp), if configured, server tasks, tool calls, and more will be metrified via otlp API.

docs/protocol.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,8 @@ type ChatContent =
515515
| ChatReasonStartedContent
516516
| ChatReasonTextContent
517517
| ChatReasonFinishedContent
518+
| ChatHookActionStartedContent
519+
| ChatHookActionFinishedContent
518520
| ChatToolCallPrepareContent
519521
| ChatToolCallRunContent
520522
| ChatToolCallRunningContent
@@ -600,6 +602,67 @@ interface ChatReasonFinishedContent {
600602
totalTimeMs: number;
601603
}
602604

605+
/**
606+
* A hook action started to run
607+
*
608+
*/
609+
interface ChatHookActionStartedContent {
610+
type: 'hookActionStarted';
611+
612+
/**
613+
* The id of this hook
614+
*/
615+
id: string;
616+
617+
/**
618+
* The name of this hook
619+
*/
620+
name: string;
621+
622+
/**
623+
* The type of this hook action
624+
*/
625+
actionType: 'shell';
626+
}
627+
628+
/**
629+
* A hook action finished
630+
*
631+
*/
632+
interface ChatHookActionFinishedContent {
633+
type: 'hookActionFinished';
634+
635+
/**
636+
* The id of this hook
637+
*/
638+
id: string;
639+
640+
/**
641+
* The name of this hook
642+
*/
643+
name: string;
644+
645+
/**
646+
* The type of this hook action
647+
*/
648+
actionType: 'shell';
649+
650+
/**
651+
* The status code of this hook
652+
*/
653+
status: number;
654+
655+
/**
656+
* The output of this hook if any
657+
*/
658+
output?: string;
659+
660+
/**
661+
* The error of this hook if any
662+
*/
663+
error?: string;
664+
}
665+
603666
/**
604667
* URL content message from the LLM
605668
*/

images/features/hooks.png

45.2 KB
Loading

src/eca/config.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
".*-c\\s+[\"'].*open.*[\"']w[\"'].*",
8282
".*bash.*-c.*>.*"]}}}}}}}
8383
:defaultModel nil
84+
:hooks {}
8485
:rules []
8586
:commands []
8687
:disabledTools []

src/eca/features/chat.clj

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
[eca.db :as db]
88
[eca.features.commands :as f.commands]
99
[eca.features.context :as f.context]
10+
[eca.features.hooks :as f.hooks]
1011
[eca.features.index :as f.index]
1112
[eca.features.login :as f.login]
1213
[eca.features.prompt :as f.prompt]
@@ -33,8 +34,32 @@
3334
:role role
3435
:content content}))
3536

36-
(defn finish-chat-prompt! [status {:keys [chat-id db* metrics on-finished-side-effect] :as chat-ctx}]
37+
(defn ^:private notify-before-hook-action! [chat-ctx {:keys [id name type]}]
38+
(send-content! chat-ctx :system
39+
{:type :hookActionStarted
40+
:action-type type
41+
:name name
42+
:id id}))
43+
44+
(defn ^:private notify-after-hook-action! [chat-ctx {:keys [id name output error status type]}]
45+
(send-content! chat-ctx :system
46+
{:type :hookActionFinished
47+
:action-type type
48+
:id id
49+
:name name
50+
:status status
51+
:output output
52+
:error error}))
53+
54+
(defn finish-chat-prompt! [status {:keys [message chat-id db* metrics config on-finished-side-effect] :as chat-ctx}]
3755
(swap! db* assoc-in [:chats chat-id :status] status)
56+
(f.hooks/trigger-if-matches! :postPrompt
57+
{:chat-id chat-id
58+
:prompt message}
59+
{:on-before-action (partial notify-before-hook-action! chat-ctx)
60+
:on-after-action (partial notify-after-hook-action! chat-ctx)}
61+
@db*
62+
config)
3863
(send-content! chat-ctx :system
3964
{:type :progress
4065
:state :finished})
@@ -153,6 +178,10 @@
153178
{:status :rejected
154179
:actions [:send-toolCallRejected]}
155180

181+
[:execution-approved :hook-rejected]
182+
{:status :rejected
183+
:actions [:set-decision-reason]}
184+
156185
[:execution-approved :execution-start]
157186
{:status :executing
158187
:actions [:set-start-time :add-future :send-toolCallRunning :send-progress]}
@@ -163,7 +192,7 @@
163192

164193
[:cleanup :cleanup-finished]
165194
{:status :completed
166-
:actions [:destroy-all-resources :remove-all-resources :remove-all-promises :remove-future]}
195+
:actions [:destroy-all-resources :remove-all-resources :remove-all-promises :remove-future :trigger-post-tool-call-hook]}
167196

168197
[:executing :resources-created]
169198
{:status :executing
@@ -304,6 +333,19 @@
304333
:details (:details event-data)
305334
:summary (:summary event-data))))
306335

336+
:trigger-post-tool-call-hook
337+
(let [tool-call-state (get-tool-call-state @db* (:chat-id chat-ctx) tool-call-id)]
338+
(f.hooks/trigger-if-matches!
339+
:postToolCall
340+
{:chat-id (:chat-id chat-ctx)
341+
:tool-name (:name tool-call-state)
342+
:server (:server tool-call-state)
343+
:arguments (:arguments tool-call-state)}
344+
{:on-before-action (partial notify-before-hook-action! chat-ctx)
345+
:on-after-action (partial notify-after-hook-action! chat-ctx)}
346+
@db*
347+
(:config chat-ctx)))
348+
307349
;; Actions on parts of the state
308350
:deliver-approval-false
309351
(deliver (get-in @db* [:chats (:chat-id chat-ctx) :tool-calls tool-call-id :approved?*])
@@ -581,9 +623,10 @@
581623
(when-not (string/blank? @received-msgs*)
582624
(add-to-history! {:role "assistant" :content [{:type :text :text @received-msgs*}]})
583625
(reset! received-msgs* ""))
584-
(let [any-rejected-tool-call?* (atom false)]
626+
(let [any-rejected-tool-call?* (atom nil)]
585627
(run! (fn do-tool-call [{:keys [id name arguments] :as tool-call}]
586628
(let [approved?* (promise) ; created here, stored in the state.
629+
hook-approved?* (atom true)
587630
details (f.tools/tool-call-details-before-invocation name arguments)
588631
summary (f.tools/tool-call-summary all-tools name arguments config)
589632
origin (tool-name->origin name all-tools)
@@ -616,8 +659,24 @@
616659
:text "Tool call rejected by user config"}})
617660
(logger/warn logger-tag "Unknown value of approval in config"
618661
{:approval approval :tool-call-id id})))
619-
;; Execute each tool call concurrently
620-
(if @approved?* ;TODO: Should there be a timeout here? If so, what would be the state transitions?
662+
;; TODO: Should there be a timeout here? If so, what would be the state transitions?
663+
@approved?* ;; wait for user respond before checking hook
664+
(f.hooks/trigger-if-matches! :preToolCall
665+
{:chat-id chat-id
666+
:tool-name name
667+
:server server
668+
:arguments arguments}
669+
{:on-before-action (partial notify-before-hook-action! chat-ctx)
670+
:on-after-action (fn [result]
671+
(when (= 2 (:status result))
672+
(transition-tool-call! db* chat-ctx id :hook-rejected
673+
{:reason {:code :hook-rejected
674+
:text (str "Tool call rejected by hook, output: " (:output result))}})
675+
(reset! hook-approved?* false))
676+
(notify-after-hook-action! chat-ctx result))}
677+
db
678+
config)
679+
(if (and @approved?* @hook-approved?*)
621680
;; assert: In :execution-approved or :stopping or :cleanup
622681
(when-not (#{:stopping :cleanup} (:status (get-tool-call-state @db* chat-id id)))
623682
(assert-chat-not-stopped! chat-ctx)
@@ -695,7 +754,7 @@
695754
:content (assoc tool-call :output {:error true
696755
:contents [{:text text
697756
:type :text}]})})
698-
(reset! any-rejected-tool-call?* true)
757+
(reset! any-rejected-tool-call?* code)
699758
(transition-tool-call! db* chat-ctx id :send-reject
700759
{:origin origin
701760
:name name
@@ -741,13 +800,21 @@
741800
:ex-data (ex-data t)
742801
:message (.getMessage t)
743802
:cause (.getCause t)})))))))
744-
(if @any-rejected-tool-call?*
803+
(if-let [reason-code @any-rejected-tool-call?*]
745804
(do
746-
(send-content! chat-ctx :system
747-
{:type :text
748-
:text "Tell ECA what to do differently for the rejected tool(s)"})
749-
(add-to-history! {:role "user" :content [{:type :text
750-
:text "I rejected one or more tool calls with the following reason"}]})
805+
(if (= :hook-rejected reason-code)
806+
(do
807+
(send-content! chat-ctx :system
808+
{:type :text
809+
:text "Tool rejected by hook"})
810+
(add-to-history! {:role "user" :content [{:type :text
811+
:text "A user hook rejected one or more tool calls with the following reason"}]}))
812+
(do
813+
(send-content! chat-ctx :system
814+
{:type :text
815+
:text "Tell ECA what to do differently for the rejected tool(s)"})
816+
(add-to-history! {:role "user" :content [{:type :text
817+
:text "I rejected one or more tool calls with the following reason"}]})))
751818
(finish-chat-prompt! :idle chat-ctx)
752819
nil)
753820
{:new-messages (get-in @db* [:chats chat-id :messages])})))
@@ -882,6 +949,7 @@
882949
expanded-prompt-contexts
883950
image-contents)}]
884951
chat-ctx {:chat-id chat-id
952+
:message message
885953
:contexts contexts
886954
:behavior selected-behavior
887955
:behavior-config behavior-config
@@ -892,7 +960,21 @@
892960
:metrics metrics
893961
:config config
894962
:messenger messenger}
895-
decision (message->decision message)]
963+
decision (message->decision message)
964+
hook-outputs* (atom [])
965+
_ (f.hooks/trigger-if-matches! :prePrompt
966+
{:chat-id chat-id
967+
:prompt message}
968+
{:on-before-action (partial notify-before-hook-action! chat-ctx)
969+
:on-after-action (fn [result]
970+
(when (= 0 (:status result))
971+
(reset! hook-outputs* (:outputs result)))
972+
(notify-after-hook-action! chat-ctx result))}
973+
db
974+
config)
975+
user-messages (if (seq @hook-outputs*)
976+
(update-in user-messages [0 :content 0 :text] str " " (string/join "\n" @hook-outputs*))
977+
user-messages)]
896978
(swap! db* assoc-in [:chats chat-id :status] :running)
897979
(send-content! chat-ctx :user {:type :text
898980
:text (str message "\n")})

0 commit comments

Comments
 (0)