Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 12 additions & 0 deletions docs/config/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 18 additions & 4 deletions src/eca/features/tools/ask_user.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand Down
21 changes: 17 additions & 4 deletions src/eca/remote/messenger.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)]
Expand Down Expand Up @@ -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.
Expand Down
52 changes: 50 additions & 2 deletions test/eca/features/tools/ask_user_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@

(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
{:messenger (or messenger (h/messenger))
: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"
Expand Down Expand Up @@ -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?
Expand Down
16 changes: 16 additions & 0 deletions test/eca/remote/messenger_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading