Skip to content

Commit 6fe08a4

Browse files
ericdalloeca-agent
andcommitted
Surface ask_user as approval="ask" to preToolCall hooks
ask_user always blocks waiting for the user regardless of trust mode, so existing notification hooks matching .approval == "ask" should also fire for it. The hook input is forced to :ask only when the resolved approval is :allow; the actual decision stays :allow so the tool executes normally, and explicit :deny rules are preserved. 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
1 parent d1753f7 commit 6fe08a4

4 files changed

Lines changed: 91 additions & 2 deletions

File tree

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+
- `preToolCall` hooks now receive `approval: "ask"` for the native `ask_user` tool so notification hooks (e.g. matching `.approval == "ask"`) also fire when the chat is blocked waiting for a user answer, regardless of trust mode.
6+
57
## 0.129.2
68

79
- Add support for gpt-5.5 variants

docs/config/hooks.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ To reject a tool call, either output `{"approval": "deny"}` or exit with code `2
102102
}
103103
```
104104

105+
The same hook also fires for the native `ask_user` tool: it always blocks
106+
waiting for a user answer (regardless of trust mode), so `preToolCall`
107+
receives `approval: "ask"` for it. This means a single `'.approval == "ask"'`
108+
hook covers both "tool call needs approval" and "assistant is waiting on a
109+
user question".
110+
105111
=== "Inject context on chat start"
106112

107113
```javascript title="~/.config/eca/config.json"

src/eca/features/chat/tool_calls.clj

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,16 @@
546546
trusted? (= :trust/allow approval)
547547
effective-approval (if trusted? :allow approval)
548548

549+
;; ask_user always blocks waiting for the user regardless of trust/auto-allow.
550+
;; Surface that as :ask to preToolCall hooks so notification hooks
551+
;; (e.g. matching .approval == "ask") also fire when the chat is blocked on
552+
;; a user question. Only override when allowed; explicit deny is preserved.
553+
hook-approval (if (and (= "eca" server-name)
554+
(= "ask_user" name)
555+
(= :allow effective-approval))
556+
:ask
557+
effective-approval)
558+
549559
;; 2. Run hooks to collect modifications and approval overrides
550560
hook-state* (atom {:hook-results []
551561
:approval-override nil
@@ -560,7 +570,7 @@
560570
{:tool-name name
561571
:server server-name
562572
:tool-input arguments
563-
:approval effective-approval})
573+
:approval hook-approval})
564574
{:on-before-action on-before-hook-action
565575
:on-after-action (fn [result]
566576
(on-after-hook-action result)

test/eca/features/chat/tool_calls_test.clj

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,78 @@
276276
:approval-override nil
277277
:hook-rejected? false
278278
:arguments-modified? true}
279-
plan)))))))
279+
plan))))))
280+
281+
(testing "ask_user with allowed config sends :ask to preToolCall hook"
282+
(h/reset-components!)
283+
(let [tool-call {:id "call-1"
284+
:full-name "eca__ask_user"
285+
:arguments {"question" "Why?"}}
286+
all-tools [{:name "ask_user"
287+
:full-name "eca__ask_user"
288+
:origin :eca
289+
:server {:name "eca"}}]
290+
db (h/db)
291+
config (h/config)
292+
agent-name :default
293+
chat-id "test-chat"
294+
hook-data* (atom nil)]
295+
(with-redefs [f.tools/approval (constantly :allow)
296+
f.hooks/trigger-if-matches! (fn [event data _ _ _]
297+
(when (= event :preToolCall)
298+
(reset! hook-data* data)))]
299+
(let [plan (#'tc/decide-tool-call-action tool-call all-tools db config agent-name chat-id)]
300+
(is (= :ask (:approval @hook-data*))
301+
"preToolCall hook receives :ask so notification hooks fire while waiting on user")
302+
(is (match? {:decision :allow
303+
:hook-rejected? false}
304+
plan)
305+
"tool actually executes (:allow) so the question still reaches the user")))))
306+
307+
(testing "ask_user with denied config keeps :deny in preToolCall hook"
308+
(h/reset-components!)
309+
(let [tool-call {:id "call-1"
310+
:full-name "eca__ask_user"
311+
:arguments {"question" "Why?"}}
312+
all-tools [{:name "ask_user"
313+
:full-name "eca__ask_user"
314+
:origin :eca
315+
:server {:name "eca"}}]
316+
db (h/db)
317+
config (h/config)
318+
agent-name :default
319+
chat-id "test-chat"
320+
hook-data* (atom nil)]
321+
(with-redefs [f.tools/approval (constantly :deny)
322+
f.hooks/trigger-if-matches! (fn [event data _ _ _]
323+
(when (= event :preToolCall)
324+
(reset! hook-data* data)))]
325+
(let [plan (#'tc/decide-tool-call-action tool-call all-tools db config agent-name chat-id)]
326+
(is (= :deny (:approval @hook-data*))
327+
"explicit :deny is preserved, not overridden to :ask")
328+
(is (= :deny (:decision plan)))))))
329+
330+
(testing "non ask_user tool keeps :allow in preToolCall hook (regression)"
331+
(h/reset-components!)
332+
(let [tool-call {:id "call-1"
333+
:full-name "eca__test_tool"
334+
:arguments {:foo "bar"}}
335+
all-tools [{:name "test_tool"
336+
:full-name "eca__test_tool"
337+
:origin :eca
338+
:server {:name "eca"}}]
339+
db (h/db)
340+
config (h/config)
341+
agent-name :default
342+
chat-id "test-chat"
343+
hook-data* (atom nil)]
344+
(with-redefs [f.tools/approval (constantly :allow)
345+
f.hooks/trigger-if-matches! (fn [event data _ _ _]
346+
(when (= event :preToolCall)
347+
(reset! hook-data* data)))]
348+
(#'tc/decide-tool-call-action tool-call all-tools db config agent-name chat-id)
349+
(is (= :allow (:approval @hook-data*))
350+
"ask_user override does not leak to other tools")))))
280351

281352
(deftest on-tools-called!-returns-provider-auth-test
282353
(testing "returns refreshed provider auth in result after token is renewed during tool execution"

0 commit comments

Comments
 (0)