From 41c786c19a63b1072b9d6d260f81159aaf32b4e0 Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Mon, 12 Oct 2026 10:19:42 +0200 Subject: [PATCH] Add trust mode timeout for ask_user questions New `toolCall.askUser.trustModeTimeoutSeconds` setting. When the chat is in trust mode and the user does not answer an ask_user question in time, the tool returns a non-error result that tells the LLM to continue with its best judgment and state its assumptions. The pending editor request is cancelled; clients close the question when the tool call finishes. The remote messenger question result now implements Future, like the jsonrpc4clj request it wraps, so future-cancel also drops its pending entry and the editor request. --- CHANGELOG.md | 1 + docs/config.json | 14 ++++++ docs/config/tools.md | 12 ++++++ src/eca/features/tools/ask_user.clj | 22 ++++++++-- src/eca/remote/messenger.clj | 21 +++++++-- test/eca/features/tools/ask_user_test.clj | 52 ++++++++++++++++++++++- test/eca/remote/messenger_test.clj | 16 +++++++ 7 files changed, 128 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df44e53e..8f68f91b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `toolCall.askUser.trustModeTimeoutSeconds`: in trust mode, an unanswered `ask_user` question times out and the LLM continues with its best judgment. - Fix "Waiting for tool call approval" progress being replaced by "Generating" when another tool call finishes while one still waits for approval. eca-emacs#334 ## 0.163.1 diff --git a/docs/config.json b/docs/config.json index f4a2e9614..4c726bfa0 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1282,6 +1282,20 @@ } } }, + "askUser": { + "type": "object", + "description": "Configuration for the ask_user tool.", + "markdownDescription": "Configuration for the `ask_user` tool.", + "additionalProperties": false, + "properties": { + "trustModeTimeoutSeconds": { + "type": "integer", + "minimum": 1, + "description": "In trust mode only, seconds to wait for an answer. After that the LLM continues without one. Unset waits until the user answers.", + "markdownDescription": "In trust mode only, seconds to wait for an answer. After that the LLM continues without one. Unset waits until the user answers." + } + } + }, "shellCommand": { "type": "object", "description": "Configuration for the shell_command tool.", diff --git a/docs/config/tools.md b/docs/config/tools.md index c9d4623d5..267ac9acb 100644 --- a/docs/config/tools.md +++ b/docs/config/tools.md @@ -689,6 +689,18 @@ Trust mode auto-accepts all tool calls in a chat (it never overrides `deny`). Ed } ``` +In trust mode, `ask_user` questions still wait for your answer. Set `toolCall.askUser.trustModeTimeoutSeconds` to let the LLM continue without an answer after that many seconds: + +```javascript title="~/.config/eca/config.json" +{ + "toolCall": { + "askUser": { + "trustModeTimeoutSeconds": 300 + } + } +} +``` + ### Default approval rules Globally ECA allows its read-only builtin tools and asks for everything else: diff --git a/src/eca/features/tools/ask_user.clj b/src/eca/features/tools/ask_user.clj index 0797d0904..604dc8716 100644 --- a/src/eca/features/tools/ask_user.clj +++ b/src/eca/features/tools/ask_user.clj @@ -48,10 +48,11 @@ normalized))) (defn ^:private ask-user - [arguments {:keys [messenger db* chat-id tool-call-id]}] + [arguments {:keys [messenger config db* chat-id tool-call-id trust]}] (let [question (get arguments "question") options (normalize-options (get arguments "options")) - allow-freeform (get arguments "allowFreeform" true)] + allow-freeform (get arguments "allowFreeform" true) + timeout-secs (get-in config [:toolCall :askUser :trustModeTimeoutSeconds])] (if (or (nil? question) (string/blank? question)) (tools.util/single-text-content "INVALID_ARGS: `question` is required and must not be blank." :error) (let [request-id (str (random-uuid)) @@ -68,9 +69,22 @@ {:chat-id chat-id :role :system :content {:type :progress :state :running :text "Waiting answer"}}) - (let [response @(messenger/ask-question messenger params)] - (if (:cancelled response) + (let [pending (messenger/ask-question messenger params) + response (if (and trust (pos-int? timeout-secs)) + (deref pending (* 1000 timeout-secs) ::timeout) + @pending)] + (cond + (= ::timeout response) + (do + (future-cancel pending) + (tools.util/single-text-content + (format "The user did not answer within %s seconds. Continue with your best judgment and state the assumptions you made." + timeout-secs))) + + (:cancelled response) (tools.util/single-text-content "User cancelled the question." :error) + + :else (tools.util/single-text-content (str "User answered: " (:answer response))))) (catch Exception e (logger/error logger-tag "Error asking user question: %s" (ex-message e)) diff --git a/src/eca/remote/messenger.clj b/src/eca/remote/messenger.clj index f80d6d5a7..a4789a3bc 100644 --- a/src/eca/remote/messenger.clj +++ b/src/eca/remote/messenger.clj @@ -17,14 +17,15 @@ ;; must not prevent cleanup of the other handle or completion of the caller. (try (future-cancel handle) (catch Exception _ nil)))) -(defn ^:private question-result [result] +(defn ^:private question-result [result cancel!] ;; Keep the registry promise raw: late handle attachment must wait for ;; publication without rethrowing a winning editor error. (let [unwrap (fn [response] (if (instance? Exception response) (throw response) response)) - timeout-sentinel (Object.)] + timeout-sentinel (Object.) + cancelled-response {:answer nil :cancelled true}] (reify clojure.lang.IDeref (deref [_] (unwrap @result)) @@ -35,7 +36,19 @@ timeout-value (unwrap response)))) clojure.lang.IPending - (isRealized [_] (realized? result))))) + (isRealized [_] (realized? result)) + ;; Same contract as the jsonrpc4clj PendingRequest it wraps. + java.util.concurrent.Future + (cancel [_ _interrupt?] (boolean (cancel! cancelled-response))) + ;; Only cancel delivers this exact instance. + (isCancelled [_] (identical? cancelled-response (deref result 0 nil))) + (isDone [_] (realized? result)) + (get [this] (deref this)) + (get [this timeout unit] + (let [response (deref this (.toMillis unit timeout) timeout-sentinel)] + (if (identical? timeout-sentinel response) + (throw (java.util.concurrent.TimeoutException.)) + response)))))) (defn ^:private resolve-question! [pending-questions* request-id response cancel-handles?] (let [[old _] (swap-vals! pending-questions* dissoc request-id)] @@ -141,7 +154,7 @@ ;; This check does not serialize broadcasting with concurrent resolution. (when (identical? result (get-in @pending-questions* [request-id :promise])) (sse/broadcast! sse-connections* "chat:ask-question" (->camel wire-params))) - (question-result result)))) + (question-result result #(resolve-question! pending-questions* request-id % true))))) (defn make-broadcast-messenger "Creates a BroadcastMessenger with a fresh pending-questions registry. diff --git a/test/eca/features/tools/ask_user_test.clj b/test/eca/features/tools/ask_user_test.clj index 428821ff1..268a1552c 100644 --- a/test/eca/features/tools/ask_user_test.clj +++ b/test/eca/features/tools/ask_user_test.clj @@ -8,7 +8,7 @@ (h/reset-components-before-test) -(defn- call-ask-user [arguments & [{:keys [messenger config chat-id tool-call-id db*] +(defn- call-ask-user [arguments & [{:keys [messenger config chat-id tool-call-id db* trust] :or {tool-call-id "test-tool-call-id"}}]] ((get-in f.tools.ask-user/definitions ["ask_user" :handler]) arguments @@ -16,7 +16,8 @@ :config (or config (h/config)) :chat-id (or chat-id "test-chat-id") :tool-call-id tool-call-id - :db* db*})) + :db* db* + :trust trust})) (deftest ask-user-option-selected-test (testing "User selects a predefined option" @@ -61,6 +62,53 @@ :text "User cancelled the question."}]} (call-ask-user {"question" "Which framework?"}))))) +(deftest ask-user-trust-mode-timeout-test + (let [config {:toolCall {:askUser {:trustModeTimeoutSeconds 300}}} + answer {:answer "yes" :cancelled false} + returning (fn [result] + (reify messenger/IMessenger + (chat-content-received [_ _data]) + (ask-question [_ _params] result))) + ;; Records each wait and cancel. The timed wait returns the answer only + ;; when answered? is true. + pending (fn [events* & {:keys [answered?]}] + (reify + clojure.lang.IDeref + (deref [_] (swap! events* conj :untimed) answer) + clojure.lang.IBlockingDeref + (deref [_ _ms timeout-value] + (swap! events* conj :timed) + (if answered? answer timeout-value)) + java.util.concurrent.Future + (cancel [_ _] (swap! events* conj :cancel) true)))] + (testing "trust on: no answer in time continues and cancels the request" + (let [events* (atom [])] + (is (match? + {:error false + :contents [{:type :text + :text "The user did not answer within 300 seconds. Continue with your best judgment and state the assumptions you made."}]} + (call-ask-user {"question" "Which DB?"} + {:messenger (returning (pending events*)) :config config :trust true}))) + (is (= [:timed :cancel] @events*)))) + (testing "trust on: an answer in time is returned without a cancel" + (let [events* (atom [])] + (is (match? + {:error false + :contents [{:type :text :text "User answered: yes"}]} + (call-ask-user {"question" "Which DB?"} + {:messenger (returning (pending events* :answered? true)) :config config :trust true}))) + (is (= [:timed] @events*)))) + (testing "trust off: waits without timeout" + (let [events* (atom [])] + (call-ask-user {"question" "Which DB?"} + {:messenger (returning (pending events*)) :config config :trust false}) + (is (= [:untimed] @events*)))) + (testing "trust on without the setting: waits without timeout" + (let [events* (atom [])] + (call-ask-user {"question" "Which DB?"} + {:messenger (returning (pending events*)) :config {} :trust true}) + (is (= [:untimed] @events*)))))) + (deftest ask-user-missing-question-test (testing "Missing question parameter" (is (match? diff --git a/test/eca/remote/messenger_test.clj b/test/eca/remote/messenger_test.clj index 8397d66cf..ae81a35b2 100644 --- a/test/eca/remote/messenger_test.clj +++ b/test/eca/remote/messenger_test.clj @@ -439,6 +439,22 @@ (is (future-cancelled? inner-result) "the editor's pending request must be cancelled so the server retracts it")))) +(deftest ask-question-future-cancel-resolves-question-test + (testing "future-cancel resolves the question as cancelled, cancels the editor request and follows the Future contract" + (let [inner-result (java.util.concurrent.CompletableFuture.) + inner (reify messenger/IMessenger + (ask-question [_ _params] inner-result)) + broadcast-messenger (remote.messenger/make-broadcast-messenger inner (sse/create-connections)) + result (messenger/ask-question broadcast-messenger {:chat-id "c1" :question "Q?" :request-id "req-1"})] + (is (not (future-done? result))) + (is (true? (future-cancel result))) + (is (future-done? result)) + (is (future-cancelled? result)) + (is (= {:answer nil :cancelled true} (deref result 0 :pending))) + (is (future-cancelled? inner-result)) + (is (nil? (remote.messenger/answer-question! broadcast-messenger "req-1" "late" false)) + "a late remote answer finds no pending question")))) + (deftest ask-question-editor-answer-wins-test (testing "an editor answer resolves the call and cleans up the SSE pending entry" (let [inner-promise (promise)