Skip to content

Commit 545594f

Browse files
committed
Support parallel tool call.
1 parent e381b64 commit 545594f

File tree

8 files changed

+135
-126
lines changed

8 files changed

+135
-126
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+
- Support parallel tool call.
6+
57
## 0.22.0
68

79
- Improve `eca_shell_command` to handle better error outputs.

src/eca/features/chat.clj

Lines changed: 73 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -164,72 +164,81 @@
164164
(swap! tool-call-by-id* update-in [id :args] str arguments-text)
165165
(send-content! chat-ctx :assistant
166166
(assoc-some
167-
{:type :toolCallPrepare
168-
:name name
169-
:origin (tool-name->origin name all-tools)
170-
:arguments-text (get-in @tool-call-by-id* [id :args])
171-
:id id
172-
:manual-approval manual-approval?}
173-
:summary (f.tools/tool-call-summary all-tools name nil))))
174-
:on-tool-called (fn [{:keys [id name arguments] :as tool-call}]
175-
(assert-chat-not-stopped! chat-ctx)
176-
(let [approved?* (promise)
177-
details (f.tools/get-tool-call-details name arguments)
178-
summary (f.tools/tool-call-summary all-tools name arguments)]
179-
(send-content! chat-ctx :assistant
180-
(assoc-some
181-
{:type :toolCallRun
182-
:name name
183-
:origin (tool-name->origin name all-tools)
184-
:arguments arguments
185-
:id id
186-
:manual-approval manual-approval?}
187-
:details details
188-
:summary summary))
189-
(swap! db* assoc-in [:chats chat-id :tool-calls id :approved?*] approved?*)
190-
(when-not (string/blank? @received-msgs*)
191-
(add-to-history! {:role "assistant" :content @received-msgs*})
192-
(reset! received-msgs* ""))
193-
(if manual-approval?
194-
(send-content! chat-ctx :system
195-
{:type :progress
196-
:state :running
197-
:text "Waiting for tool call approval"})
198-
;; Otherwise auto approve
199-
(deliver approved?* true))
200-
(if @approved?*
201-
(let [result (f.tools/call-tool! name arguments @db* config)]
202-
(add-to-history! {:role "tool_call" :content tool-call})
203-
(add-to-history! {:role "tool_call_output" :content (assoc tool-call :output result)})
204-
(send-content! chat-ctx :assistant
205-
(assoc-some
206-
{:type :toolCalled
207-
:origin (tool-name->origin name all-tools)
167+
{:type :toolCallPrepare
208168
:name name
209-
:arguments arguments
210-
:error (:error result)
211-
:id id
212-
:outputs (:contents result)}
213-
:details details
214-
:summary summary)))
215-
(do
216-
(add-to-history! {:role "tool_call" :content tool-call})
217-
(add-to-history! {:role "tool_call_output" :content (assoc tool-call :output {:error true
218-
:contents [{:text "Tool call rejected by user"
219-
:type :text}]})})
220-
(send-content! chat-ctx :assistant
221-
(assoc-some
222-
{:type :toolCallRejected
223169
:origin (tool-name->origin name all-tools)
224-
:name name
225-
:arguments arguments
226-
:reason :user
227-
:id id}
228-
:details details
229-
:summary summary))))
230-
(swap! tool-call-by-id* dissoc id)
231-
(send-content! chat-ctx :system {:type :progress :state :running :text "Generating"})
232-
{:new-messages (get-in @db* [:chats chat-id :messages])}))
170+
:arguments-text (get-in @tool-call-by-id* [id :args])
171+
:id id
172+
:manual-approval manual-approval?}
173+
:summary (f.tools/tool-call-summary all-tools name nil))))
174+
:on-tools-called (fn [tool-calls]
175+
(assert-chat-not-stopped! chat-ctx)
176+
;; Flush any pending assistant text once before processing multiple tool calls
177+
(when-not (string/blank? @received-msgs*)
178+
(add-to-history! {:role "assistant" :content @received-msgs*})
179+
(reset! received-msgs* ""))
180+
(let [calls (doall
181+
(for [{:keys [id name arguments] :as tool-call} tool-calls]
182+
(let [approved?* (promise)
183+
details (f.tools/get-tool-call-details name arguments)
184+
summary (f.tools/tool-call-summary all-tools name arguments)]
185+
;; Inform UI the tool is about to run and store approval promise
186+
(send-content! chat-ctx :assistant
187+
(assoc-some
188+
{:type :toolCallRun
189+
:name name
190+
:origin (tool-name->origin name all-tools)
191+
:arguments arguments
192+
:id id
193+
:manual-approval manual-approval?}
194+
:details details
195+
:summary summary))
196+
(swap! db* assoc-in [:chats chat-id :tool-calls id :approved?*] approved?*)
197+
(if manual-approval?
198+
(send-content! chat-ctx :system
199+
{:type :progress
200+
:state :running
201+
:text "Waiting for tool call approval"})
202+
;; Otherwise auto approve
203+
(deliver approved?* true))
204+
;; Execute each tool call concurrently
205+
(future
206+
(if @approved?*
207+
(let [result (f.tools/call-tool! name arguments @db* config)]
208+
(add-to-history! {:role "tool_call" :content tool-call})
209+
(add-to-history! {:role "tool_call_output" :content (assoc tool-call :output result)})
210+
(send-content! chat-ctx :assistant
211+
(assoc-some
212+
{:type :toolCalled
213+
:origin (tool-name->origin name all-tools)
214+
:name name
215+
:arguments arguments
216+
:error (:error result)
217+
:id id
218+
:outputs (:contents result)}
219+
:details details
220+
:summary summary)))
221+
(do
222+
(add-to-history! {:role "tool_call" :content tool-call})
223+
(add-to-history! {:role "tool_call_output"
224+
:content (assoc tool-call :output {:error true
225+
:contents [{:text "Tool call rejected by user"
226+
:type :text}]})})
227+
(send-content! chat-ctx :assistant
228+
(assoc-some
229+
{:type :toolCallRejected
230+
:origin (tool-name->origin name all-tools)
231+
:name name
232+
:arguments arguments
233+
:reason :user
234+
:id id}
235+
:details details
236+
:summary summary))))
237+
(swap! tool-call-by-id* dissoc id)))))]
238+
;; Wait all tool calls to complete before returning
239+
(run! deref calls)
240+
(send-content! chat-ctx :system {:type :progress :state :running :text "Generating"})
241+
{:new-messages (get-in @db* [:chats chat-id :messages])}))
233242
:on-reason (fn [{:keys [status id text external-id]}]
234243
(assert-chat-not-stopped! chat-ctx)
235244
(case status

src/eca/features/tools.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
(logger/debug logger-tag "Tool call result: " result)
5858
result)
5959
(catch Exception e
60-
(logger/warn logger-tag (format "Error calling tool %s: %s" name (.getMessage e)))
60+
(logger/warn logger-tag (format "Error calling tool %s: %s\n%s" name (.getMessage e) (with-out-str (.printStackTrace e))))
6161
{:error true
6262
:contents [{:type :text
6363
:text (str "Error calling tool: " (.getMessage e))}]}))))

src/eca/llm_api.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181

8282
(defn complete!
8383
[{:keys [model model-config instructions reason? user-messages config on-first-response-received
84-
on-message-received on-error on-prepare-tool-call on-tool-called on-reason on-usage-updated
84+
on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated
8585
past-messages tools]}]
8686
(let [first-response-received* (atom false)
8787
emit-first-message-fn (fn [& args]
@@ -114,7 +114,7 @@
114114
callbacks {:on-message-received on-message-received-wrapper
115115
:on-error on-error-wrapper
116116
:on-prepare-tool-call on-prepare-tool-call-wrapper
117-
:on-tool-called on-tool-called
117+
:on-tools-called on-tools-called
118118
:on-reason on-reason-wrapper
119119
:on-usage-updated on-usage-updated}]
120120
(cond

src/eca/llm_providers/anthropic.clj

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
[{:keys [model user-messages temperature instructions max-output-tokens
9999
api-url api-key reason? reason-tokens past-messages tools web-search extra-payload]
100100
:or {temperature 1.0}}
101-
{:keys [on-message-received on-error on-reason on-prepare-tool-call on-tool-called on-usage-updated]}]
101+
{:keys [on-message-received on-error on-reason on-prepare-tool-call on-tools-called on-usage-updated]}]
102102
(let [messages (concat (normalize-messages past-messages)
103103
(normalize-messages user-messages))
104104
body (merge (assoc-some
@@ -159,23 +159,25 @@
159159
:input-cache-read-tokens (:cache_read_input_tokens usage)
160160
:output-tokens (:output_tokens usage)}))
161161
(case (-> data :delta :stop_reason)
162-
"tool_use" (doseq [content-block (vals @content-block*)]
163-
(when (= "tool_use" (:type content-block))
164-
(let [function-name (:name content-block)
165-
function-args (:input-json content-block)
166-
{:keys [new-messages]} (on-tool-called {:id (:id content-block)
167-
:name function-name
168-
:arguments (json/parse-string function-args)})
169-
messages (-> (normalize-messages new-messages)
170-
add-cache-to-last-message)]
171-
(base-request!
172-
{:rid (llm-util/gen-rid)
173-
:body (assoc body :messages messages)
174-
:api-url api-url
175-
:api-key api-key
176-
:content-block* (atom nil)
177-
:on-error on-error
178-
:on-response handle-response}))))
162+
"tool_use" (let [tool-calls (keep
163+
(fn [content-block]
164+
(when (= "tool_use" (:type content-block))
165+
{:id (:id content-block)
166+
:name (:name content-block)
167+
:arguments (json/parse-string (:input-json content-block))}))
168+
(vals @content-block*))
169+
{:keys [new-messages]} (on-tools-called tool-calls)
170+
messages (-> (normalize-messages new-messages)
171+
add-cache-to-last-message)]
172+
(reset! content-block* {})
173+
(base-request!
174+
{:rid (llm-util/gen-rid)
175+
:body (assoc body :messages messages)
176+
:api-url api-url
177+
:api-key api-key
178+
:content-block* (atom nil)
179+
:on-error on-error
180+
:on-response handle-response}))
179181
"end_turn" (do
180182
(reset! content-block* {})
181183
(on-message-received {:type :finish

src/eca/llm_providers/ollama.clj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
past-messages))
9898

9999
(defn completion! [{:keys [model user-messages reason? instructions host port past-messages tools]}
100-
{:keys [on-message-received on-error on-prepare-tool-call on-tool-called
100+
{:keys [on-message-received on-error on-prepare-tool-call on-tools-called
101101
on-reason extra-payload]}]
102102
(let [messages (concat
103103
(normalize-messages (concat [{:role "system" :content instructions}] past-messages))
@@ -124,7 +124,8 @@
124124

125125
done_reason
126126
(if-let [tool-call (get @tool-calls* rid)]
127-
(let [{:keys [new-messages]} (on-tool-called tool-call)]
127+
;; TODO support multiple tool calls
128+
(let [{:keys [new-messages]} (on-tools-called [tool-call])]
128129
(swap! tool-calls* dissoc rid)
129130
(base-completion-request!
130131
{:rid (llm-util/gen-rid)

0 commit comments

Comments
 (0)