Skip to content

Commit 45cde62

Browse files
authored
Merge pull request #145 from bombaywalla/cancel-shell-calls
Added ability to cancel tool calls.
2 parents 8a49abc + 1fe5aac commit 45cde62

File tree

7 files changed

+278
-114
lines changed

7 files changed

+278
-114
lines changed

CHANGELOG.md

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

33
## Unreleased
44

5+
- Added ability to cancel tool calls. Only the shell tool
6+
currently. #145
7+
58
## 0.64.1
69

710
- Fix duplicated arguments on `toolCallPrepare` for openai-chat API models. https://github.com/editor-code-assistant/eca-emacs/issues/56

src/eca/features/chat.clj

Lines changed: 124 additions & 45 deletions
Large diffs are not rendered by default.

src/eca/features/tools.clj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@
246246
[name arguments details result]
247247
(tools.util/tool-call-details-after-invocation name arguments details result))
248248

249+
(defn tool-call-destroy-resource!
250+
"Destroy the resource in the tool call named `name`."
251+
[name resource-kwd resource]
252+
(tools.util/tool-call-destroy-resource! name resource-kwd resource))
253+
249254
(defn refresh-tool-servers!
250255
"Updates all tool servers (native and MCP) with new behavior status."
251256
[tool-status-fn db* messenger config]

src/eca/features/tools/shell.clj

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
(def ^:private default-timeout 60000)
1616
(def ^:private max-timeout (* 60000 10))
1717

18-
(defn ^:private shell-command [arguments {:keys [db]}]
18+
(defn ^:private shell-command [arguments {:keys [db tool-call-id call-state-fn state-transition-fn]}]
1919
(let [command-args (get arguments "command")
2020
user-work-dir (get arguments "working_directory")
2121
timeout (min (or (get arguments "timeout") default-timeout) max-timeout)]
@@ -29,15 +29,37 @@
2929
(config/get-property "user.home"))
3030
_ (logger/debug logger-tag "Running command:" command-args)
3131
result (try
32-
(deref (p/process {:dir work-dir
33-
:out :string
34-
:err :string
35-
:timeout timeout
36-
:continue true} "bash -c" command-args)
37-
timeout
38-
::timeout)
32+
(if-let [proc (when-not (= :stopping (:status (call-state-fn)))
33+
(p/process {:dir work-dir
34+
:out :string
35+
:err :string
36+
:timeout timeout
37+
:continue true} "bash -c" command-args))]
38+
(do
39+
(state-transition-fn :resources-created {:resources {:process proc}})
40+
(try (deref proc
41+
timeout
42+
::timeout)
43+
(catch InterruptedException e
44+
(let [msg (or (.getMessage e) "Shell tool call was interrupted")]
45+
(logger/debug logger-tag "Shell tool call was interrupted" {:tool-call-id tool-call-id :message msg})
46+
(tools.util/tool-call-destroy-resource! "eca_shell_command" :process proc)
47+
(state-transition-fn :resources-destroyed {:resources [:process]})
48+
{:exit 1 :err msg}))))
49+
{:exit 1 :err "Tool call is :stopping, so shell rpocess not spawned"})
3950
(catch Exception e
40-
{:exit 1 :err (.getMessage e)}))
51+
;; Process did not start, or had an Exception (other than InterruptedException) during execution.
52+
(let [msg (or (.getMessage e) "Caught an Exception during execution of the shell tool")]
53+
(logger/warn logger-tag "Got an Exception during execution" {:message msg})
54+
{:exit 1 :err msg}))
55+
(finally
56+
;; If any resources remain, destroy them.
57+
(let [state (call-state-fn)]
58+
(when-let [resources (:resources state)]
59+
(doseq [[res-kwd res] resources]
60+
(tools.util/tool-call-destroy-resource! "eca_shell_command" res-kwd res))
61+
(when (#{:executing :stopping} (:status state))
62+
(state-transition-fn :resources-destroyed {:resources (keys resources)}))))))
4163
err (some-> (:err result) string/trim)
4264
out (some-> (:out result) string/trim)]
4365
(cond
@@ -90,3 +112,10 @@
90112
(let [workspace-roots (mapv (comp shared/uri->filename :uri) (:workspace-folders db))]
91113
(not-any? #(fs/starts-with? wd %) workspace-roots)))))
92114
:summary-fn #'shell-command-summary}})
115+
116+
(defmethod tools.util/tool-call-destroy-resource! :eca_shell_command [name resource-kwd resource]
117+
(logger/debug logger-tag "About to destroy resource" {:resource-kwd resource-kwd})
118+
(case resource-kwd
119+
:process (p/destroy-tree resource)
120+
(logger/warn logger-tag "Unknown resource keyword" {:tool-name name
121+
:resource-kwd resource-kwd})))

src/eca/features/tools/util.clj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@
1919
(defmethod tool-call-details-after-invocation :default [_name _arguments details _result]
2020
details)
2121

22+
(defmulti tool-call-destroy-resource!
23+
"Destroy the tool call resource."
24+
(fn [name _resource-kwd _resource] (keyword name)))
25+
26+
(defmethod tool-call-destroy-resource! :default [_name _resource-kwd _resource]
27+
;; By default, do nothing
28+
)
29+
2230
(defn single-text-content [text & [error]]
2331
{:error (boolean error)
2432
:contents [{:type :text

test/eca/features/chat_tool_call_state_test.clj

Lines changed: 55 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@
100100
"Expected state machine to contain [:execution-approved :stop-requested] transition")
101101
(is (contains? state-machine [:executing :stop-requested])
102102
"Expected state machine to contain [:executing :stop-requested] transition")
103+
(is (contains? state-machine [:stopping :stop-requested])
104+
"Expected state machine to contain [:stopping :stop-requested] transition")
105+
(is (contains? state-machine [:cleanup :stop-requested])
106+
"Expected state machine to contain [:cleanup :stop-requested] transition")
103107

104-
;; Note: :rejected, :completed and :stopped are terminal states, so no stop transitions.
105-
(is (not (contains? state-machine [:stopped :stop-requested]))
106-
"Expected :rejected state to not have stop transition defined, since it is a terminal state")
108+
;; Note: :rejected and :completed are terminal states, so no stop transitions.
107109
(is (not (contains? state-machine [:rejected :stop-requested]))
108110
"Expected :rejected state to not have stop transition defined, since it is a terminal state")
109111
(is (not (contains? state-machine [:completed :stop-requested]))
@@ -261,7 +263,7 @@
261263
(let [result (#'f.chat/transition-tool-call! db* chat-ctx tool-call-id :tool-run run-event-data)]
262264

263265
(is (match? {:status :check-approval
264-
:actions [:init-arguments :init-approval-promise :send-toolCallRun]}
266+
:actions [:init-arguments :init-approval-promise :init-future-cleanup-promise :send-toolCallRun]}
265267
result)
266268
"Expected next state to be :check-approval with actions of :init-approval-promise and :send-toolCallRun")
267269

@@ -316,7 +318,7 @@
316318
;; Step 2: :preparing -> :check-approval
317319
(let [result (#'f.chat/transition-tool-call! db* chat-ctx tool-call-id :tool-run run-event-data)]
318320
(is (match? {:status :check-approval
319-
:actions [:init-arguments :init-approval-promise :send-toolCallRun]}
321+
:actions [:init-arguments :init-approval-promise :init-future-cleanup-promise :send-toolCallRun]}
320322
result)
321323
"Expected transition to :check-approval with init promise and send run actions")
322324

@@ -451,31 +453,31 @@
451453
(let [result (#'f.chat/transition-tool-call! db* chat-ctx tool-call-id :execution-start
452454
{:name "list_files" :origin "filesystem" :arguments {:path "/tmp"}})]
453455
(is (match? {:status :executing
454-
:actions [:set-start-time :set-call-future :send-toolCallRunning :send-progress]}
456+
:actions [:set-start-time :add-future :send-toolCallRunning :send-progress]}
455457
result)
456458
"Expected transition to :executing status with no additional actions")
457459

458460
(let [tool-state (#'f.chat/get-tool-call-state @db* chat-id tool-call-id)]
459461
(is (= :executing (:status tool-state))
460462
"Expected tool call state to be in :executing status"))))
461463

462-
;; Step 2: :executing -> :completed
463-
(testing ":executing -> :completed transition"
464+
;; Step 2: :executing -> :cleanup
465+
(testing ":executing -> :cleanup transition"
464466
(let [result-data {:outputs "file1.txt\nfile2.txt\nfile3.txt"
465467
:error nil
466468
:name "list_files"
467469
:origin "filesystem"
468470
:arguments {:path "/tmp"}}
469471
result (#'f.chat/transition-tool-call! db* chat-ctx tool-call-id :execution-end result-data)]
470472

471-
(is (match? {:status :completed
472-
:actions [:send-toolCalled :log-metrics :send-progress]}
473+
(is (match? {:status :cleanup
474+
:actions [:deliver-future-cleanup-completed :send-toolCalled :log-metrics :send-progress]}
473475
result)
474-
"Expected transition to :completed with send toolCalled and record metrics actions")
476+
"Expected transition to :cleanup with send toolCalled and record metrics actions")
475477

476478
(let [tool-state (#'f.chat/get-tool-call-state @db* chat-id tool-call-id)]
477-
(is (= :completed (:status tool-state))
478-
"Expected tool call state to be in :completed status"))
479+
(is (= :cleanup (:status tool-state))
480+
"Expected tool call state to be in :cleanup status"))
479481

480482
(let [messages (h/messages)
481483
chat-messages (:chat-content-received messages)
@@ -501,15 +503,15 @@
501503
chat-id "test-chat"
502504
chat-ctx {:chat-id chat-id :request-id "req-1" :messenger (h/messenger)}]
503505

504-
;; Test :initial -> :stopped
505-
(testing ":initial -> :stopped"
506+
;; Test :initial -> :cleanup
507+
(testing ":initial -> :cleanup"
506508
(let [result (#'f.chat/transition-tool-call! db* chat-ctx "tool-initial" :stop-requested)]
507-
(is (match? {:status :stopped :actions []} result)
508-
"Expected transition from :initial to :stopped with no actions")))
509+
(is (match? {:status :cleanup :actions []} result)
510+
"Expected transition from :initial to :cleanup with no actions")))
509511

510-
;; Test :preparing -> :stopped (already covered in transition-tool-call-stop-transitions-test)
511-
;; Test :check-approval -> :stopped
512-
(testing ":check-approval -> :stopped"
512+
;; Test :preparing -> :cleanup (already covered in transition-tool-call-stop-transitions-test)
513+
;; Test :check-approval -> :cleanup
514+
(testing ":check-approval -> :cleanup"
513515
(let [approved?* (promise)]
514516
(#'f.chat/transition-tool-call! db* chat-ctx "tool-check" :tool-prepare
515517
{:name "test" :origin "test" :arguments-text "{}"})
@@ -522,8 +524,8 @@
522524
result)
523525
"Expected transition from :check-approval to :rejected with relevant actions"))))
524526

525-
;; Test :executing -> :stopping -> :stopped
526-
(testing ":executing -> :stopping -> :stopped"
527+
;; Test :executing -> :stopping -> :cleanup
528+
(testing ":executing -> :stopping -> :cleanup"
527529
(let [approved?* (promise)]
528530
(#'f.chat/transition-tool-call! db* chat-ctx "tool-executing" :tool-prepare
529531
{:name "test" :origin "test" :arguments-text "{}"})
@@ -535,18 +537,18 @@
535537

536538
(let [result (#'f.chat/transition-tool-call! db* chat-ctx "tool-executing" :stop-requested)]
537539
(is (match? {:status :stopping
538-
:actions []}
540+
:actions [:cancel-future]}
539541
result)
540542
"Expected transition from :executing to :stopping with relevant actions"))
541543
(let [result (#'f.chat/transition-tool-call! db* chat-ctx "tool-executing" :stop-attempted)]
542-
(is (match? {:status :stopped
543-
:actions [:send-toolCallRejected]}
544+
(is (match? {:status :cleanup
545+
:actions [:deliver-future-cleanup-completed :send-toolCallRejected]}
544546
result)
545-
"Expected transition from :stopping to :stopped with relevant actions"))))
547+
"Expected transition from :stopping to :cleanup with relevant actions"))))
546548

547549

548-
;; Test :completed -> :stopped (should be no-op or error)
549-
(testing ":completed -> :stopped (should handle gracefully)"
550+
;; Test :cleanup -> :cleanup (should be no-op or error)
551+
(testing ":cleanup -> :cleanup (should handle gracefully)"
550552
(let [approved?* (promise)]
551553
(#'f.chat/transition-tool-call! db* chat-ctx "tool-completed" :tool-prepare
552554
{:name "test" :origin "test" :arguments-text "{}"})
@@ -558,15 +560,15 @@
558560
(#'f.chat/transition-tool-call! db* chat-ctx "tool-completed" :execution-end
559561
{:outputs "success" :error nil :name "test" :origin "test" :arguments {}})
560562

561-
;; Now try to stop a completed tool call
562-
(is (thrown-with-msg?
563-
clojure.lang.ExceptionInfo
564-
#"Invalid state transition"
565-
(#'f.chat/transition-tool-call! db* chat-ctx "tool-completed" :stop-requested))
566-
"Expected exception as completed tool calls cannot be stopped")))
563+
;; Now try to stop a tool call in :cleanup
564+
(let [result (#'f.chat/transition-tool-call! db* chat-ctx "tool-completed" :stop-requested)]
565+
(is (match? {:status :cleanup
566+
:actions []}
567+
result)
568+
"Expected a transition from :cleanup on :stop-requested to be a nop"))))
567569

568-
;; Test :rejected -> :stopped
569-
(testing ":rejected -> :stopped (should handle gracefully)"
570+
;; Test :rejected -> :cleanup
571+
(testing ":rejected -> :cleanup (should handle gracefully)"
570572
(let [approved?* (promise)]
571573
(#'f.chat/transition-tool-call! db* chat-ctx "tool-rejected" :tool-prepare
572574
{:name "test" :origin "test" :arguments-text "{}"})
@@ -591,17 +593,17 @@
591593
chat-id "test-chat"
592594
chat-ctx {:chat-id chat-id :request-id "req-1" :messenger (h/messenger)}]
593595

594-
(testing ":preparing -> :stopped"
596+
(testing ":preparing -> :cleanup"
595597
(#'f.chat/transition-tool-call! db* chat-ctx "tool-1" :tool-prepare
596598
{:name "test" :origin "test" :arguments-text "{}"})
597599

598600
(let [result (#'f.chat/transition-tool-call! db* chat-ctx "tool-1" :stop-requested)]
599-
(is (match? {:status :stopped
601+
(is (match? {:status :cleanup
600602
:actions [:set-decision-reason :send-toolCallRejected]}
601603
result)
602-
"Expected transition to :stopped with send toolCallRejected action")
603-
(is (= :stopped (:status (#'f.chat/get-tool-call-state @db* chat-id "tool-1")))
604-
"Expected tool call state to be in :stopped status")))
604+
"Expected transition to :cleanup with send toolCallRejected action")
605+
(is (= :cleanup (:status (#'f.chat/get-tool-call-state @db* chat-id "tool-1")))
606+
"Expected tool call state to be in :cleanup status")))
605607

606608
(testing ":waiting-approval -> :rejected"
607609
(let [approved?* (promise)]
@@ -622,7 +624,7 @@
622624
(is (= false (deref approved?* 100 :timeout))
623625
"Expected promise to be delivered with false value"))))
624626

625-
(testing ":execution-approved -> :stopped"
627+
(testing ":execution-approved -> :cleanup"
626628
(let [approved?* (promise)]
627629
(#'f.chat/transition-tool-call! db* chat-ctx "tool-3" :tool-prepare
628630
{:name "test" :origin "test" :arguments-text "{}"})
@@ -631,12 +633,12 @@
631633
(#'f.chat/transition-tool-call! db* chat-ctx "tool-3" :approval-allow)
632634

633635
(let [result (#'f.chat/transition-tool-call! db* chat-ctx "tool-3" :stop-requested)]
634-
(is (match? {:status :stopped
636+
(is (match? {:status :cleanup
635637
:actions [:send-toolCallRejected]}
636638
result)
637-
"Expected transition to :stopped with send toolCallRejected action")
638-
(is (= :stopped (:status (#'f.chat/get-tool-call-state @db* chat-id "tool-3")))
639-
"Expected tool call state to be in :stopped status")))))))
639+
"Expected transition to :cleanup with send toolCallRejected action")
640+
(is (= :cleanup (:status (#'f.chat/get-tool-call-state @db* chat-id "tool-3")))
641+
"Expected tool call state to be in :cleanup status")))))))
640642

641643
(deftest test-stop-prompt-messages
642644
;; Test what messages are sent when stop-prompt is called
@@ -829,7 +831,7 @@
829831

830832
(deftest transition-tool-call-stop-during-execution-test
831833
;; Test stopping a tool call during execution
832-
(testing ":executing -> :stopping -> :stopped transition"
834+
(testing ":executing -> :stopping -> :cleanup transition"
833835
(h/reset-components!)
834836
(let [db* (h/db*)
835837
chat-id "test-chat"
@@ -858,8 +860,8 @@
858860
result (#'f.chat/transition-tool-call! db* chat-ctx tool-call-id :stop-attempted)]
859861
(is (= :stopping (:status tool-state))
860862
"Expected tool call state to be in :stopping status")
861-
(is (= :stopped (:status result))
862-
"Expected tool call state to be :stopped after :stop-requested"))
863+
(is (= :cleanup (:status result))
864+
"Expected tool call state to be :cleanup after :stop-requested"))
863865
)))
864866

865867
(deftest transition-tool-call-nonexistent-tool-call-operations-test
@@ -896,7 +898,7 @@
896898
(#'f.chat/transition-tool-call! db* chat-ctx tool-call-id :execution-start
897899
{:name "test" :server "eca" :origin "test" :arguments {}})
898900

899-
(testing ":executing -> :completed with error"
901+
(testing ":executing -> :cleanup with error"
900902
(let [error-result {:outputs nil
901903
:error "File not found: /nonexistent/path"
902904
:name "test"
@@ -905,10 +907,10 @@
905907
:arguments {}}
906908
result (#'f.chat/transition-tool-call! db* chat-ctx tool-call-id :execution-end error-result)]
907909

908-
(is (match? {:status :completed
909-
:actions [:send-toolCalled :log-metrics :send-progress]}
910+
(is (match? {:status :cleanup
911+
:actions [:deliver-future-cleanup-completed :send-toolCalled :log-metrics :send-progress]}
910912
result)
911-
"Expected transition to :completed with send toolCalled and record metrics actions")
913+
"Expected transition to :cleanup with send toolCalled and record metrics actions")
912914

913915
(let [messages (h/messages)
914916
chat-messages (:chat-content-received messages)

0 commit comments

Comments
 (0)