From acca63c95851b435df5f7fd687562eb83de9cd29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:18:41 +0000 Subject: [PATCH 1/5] Initial plan From 9634e3d06e4f758b2f4a8a1aae4176d18c6db405 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:59:16 +0000 Subject: [PATCH 2/5] Renew Copilot auth token before each LLM API request attempt Agent-Logs-Url: https://github.com/itkonen/eca/sessions/3348aaf5-3cd1-4629-99de-170ec4e63c9d Co-authored-by: itkonen <37696708+itkonen@users.noreply.github.com> --- CHANGELOG.md | 2 ++ src/eca/features/chat.clj | 3 +++ src/eca/features/login.clj | 5 +++-- src/eca/features/rewrite.clj | 3 +++ src/eca/llm_api.clj | 22 ++++++++++++++++++---- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce1a39f4..5891c837e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Renew GitHub Copilot auth token before each LLM API request attempt to prevent 401 token expired errors during long agentic workflows. + ## 0.124.2 - Fix OpenAI Responses API tool calls not executing when streaming response returns empty output, and fix spurious retries caused by stale tool-call state with Copilot encrypted IDs. #398 diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index b6dcea406..ad6090e93 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -557,6 +557,9 @@ :config config :tools all-tools :provider-auth provider-auth + :db* db* + :messenger messenger + :metrics metrics :variant (:variant chat-ctx) :subagent? (some? (get-in @db* [:chats chat-id :subagent])) :cancelled? (fn [] diff --git a/src/eca/features/login.clj b/src/eca/features/login.clj index 379766239..af66b5d4a 100644 --- a/src/eca/features/login.clj +++ b/src/eca/features/login.clj @@ -109,7 +109,8 @@ messenger {:chat {:models (sort (keys new-models))}}))) - (swap! db* assoc-in [:chats chat-id :login-provider] nil) - (swap! db* assoc-in [:chats chat-id :status] :idle) + (when chat-id + (swap! db* assoc-in [:chats chat-id :login-provider] nil) + (swap! db* assoc-in [:chats chat-id :status] :idle)) (when-not silent? (send-msg! (format "\nLogin successful! You can now use the '%s' models." provider)))) diff --git a/src/eca/features/rewrite.clj b/src/eca/features/rewrite.clj index 40d6b6b15..5ffd3ea89 100644 --- a/src/eca/features/rewrite.clj +++ b/src/eca/features/rewrite.clj @@ -55,6 +55,9 @@ :user-messages [{:role "user" :content [{:type :text :text prompt}]}] :past-messages [] :provider-auth provider-auth + :db* db* + :messenger messenger + :metrics metrics :on-first-response-received (fn [& _] (send-content! ctx {:type :started})) :on-reason (fn [{:keys [status]}] diff --git a/src/eca/llm_api.clj b/src/eca/llm_api.clj index b4e6c7af0..6a91dea7e 100644 --- a/src/eca/llm_api.clj +++ b/src/eca/llm_api.clj @@ -3,6 +3,7 @@ [babashka.fs :as fs] [clojure.string :as string] [eca.config :as config] + [eca.features.login :as f.login] [eca.features.prompt :as f.prompt] [eca.llm-providers.anthropic :as llm-providers.anthropic] [eca.llm-providers.azure] @@ -352,7 +353,7 @@ (defn sync-or-async-prompt! [{:keys [provider model model-capabilities instructions user-messages config on-first-response-received on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated on-server-web-search - past-messages tools provider-auth variant cancelled? on-retry subagent?] + past-messages tools provider-auth db* messenger metrics variant cancelled? on-retry subagent?] :or {on-first-response-received identity on-message-received identity on-error identity @@ -419,10 +420,22 @@ extra-payload (extra-payload-considering-variant model-config variant api-handler (:reason? model-capabilities)) stream? (if (not (nil? (:stream extra-payload))) (:stream extra-payload) - true)] + true) + renew-token! (fn [] + (when db* + (f.login/maybe-renew-auth-token! + {:provider provider + :on-error (fn [error-msg] + (logger/error logger-tag "Token renewal failed:" error-msg))} + {:db* db* :config config :messenger messenger :metrics metrics}))) + current-provider-auth (fn [] + (if db* + (get-in @db* [:auth provider]) + provider-auth))] (if (not stream?) (let [sync-prompt-with-retry* (fn sync-prompt-with-retry [attempt] + (renew-token!) (loop [result (prompt! {:sync? true :provider provider @@ -430,7 +443,7 @@ :model-capabilities model-capabilities :instructions instructions :tools tools - :provider-auth provider-auth + :provider-auth (current-provider-auth) :past-messages past-messages :user-messages user-messages :variant variant @@ -458,6 +471,7 @@ (sync-prompt-with-retry* 0)) (let [async-prompt-with-retry* (fn async-prompt-with-retry [attempt] + (renew-token!) (prompt! {:sync? false :provider provider @@ -465,7 +479,7 @@ :model-capabilities model-capabilities :instructions instructions :tools tools - :provider-auth provider-auth + :provider-auth (current-provider-auth) :past-messages past-messages :user-messages user-messages :variant variant From b98453158685cde6a6babfe2cff4287fa92677f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:33:17 +0000 Subject: [PATCH 3/5] Refactor: encapsulate token freshness in provider-auth-fn closure Agent-Logs-Url: https://github.com/itkonen/eca/sessions/41c45749-ba10-49b5-a449-e2e8e29ec513 Co-authored-by: itkonen <37696708+itkonen@users.noreply.github.com> --- src/eca/features/chat.clj | 5 +- src/eca/features/chat/lifecycle.clj | 13 +++ src/eca/features/rewrite.clj | 11 ++- src/eca/llm_api.clj | 131 +++++++++++++--------------- 4 files changed, 81 insertions(+), 79 deletions(-) diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index ad6090e93..5d8eaf3f6 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -556,10 +556,7 @@ (get-in @db* [:chats chat-id :messages] [])) :config config :tools all-tools - :provider-auth provider-auth - :db* db* - :messenger messenger - :metrics metrics + :provider-auth-fn (lifecycle/silent-provider-auth-fn chat-ctx) :variant (:variant chat-ctx) :subagent? (some? (get-in @db* [:chats chat-id :subagent])) :cancelled? (fn [] diff --git a/src/eca/features/chat/lifecycle.clj b/src/eca/features/chat/lifecycle.clj index c3a9c52b0..e9f11dea4 100644 --- a/src/eca/features/chat/lifecycle.clj +++ b/src/eca/features/chat/lifecycle.clj @@ -110,6 +110,19 @@ (throw (ex-info "Auth token renew failed" {})))} chat-ctx)) +(defn silent-provider-auth-fn + "Returns a 0-arity fn that silently renews provider auth if expired and returns the current auth. + Errors are logged but do not throw, so ongoing requests are not interrupted. + Use as :provider-auth-fn in sync-or-async-prompt! for proactive token refresh." + [{:keys [provider db* config messenger metrics]}] + (fn [] + (f.login/maybe-renew-auth-token! + {:provider provider + :on-error (fn [error-msg] + (logger/warn logger-tag "Token renewal failed:" error-msg))} + {:db* db* :config config :messenger messenger :metrics metrics}) + (get-in @db* [:auth provider]))) + (defn assert-chat-not-stopped! [{:keys [chat-id db* prompt-id] :as chat-ctx}] (let [chat (get-in @db* [:chats chat-id]) superseded? (and prompt-id (not= prompt-id (:prompt-id chat))) diff --git a/src/eca/features/rewrite.clj b/src/eca/features/rewrite.clj index 5ffd3ea89..3986115b2 100644 --- a/src/eca/features/rewrite.clj +++ b/src/eca/features/rewrite.clj @@ -54,10 +54,13 @@ :config config :user-messages [{:role "user" :content [{:type :text :text prompt}]}] :past-messages [] - :provider-auth provider-auth - :db* db* - :messenger messenger - :metrics metrics + :provider-auth-fn (fn [] + (f.login/maybe-renew-auth-token! + {:provider provider + :on-error (fn [error-msg] + (logger/error logger-tag (format "Auth token renew failed: %s" error-msg)))} + ctx) + (get-in @db* [:auth provider])) :on-first-response-received (fn [& _] (send-content! ctx {:type :started})) :on-reason (fn [{:keys [status]}] diff --git a/src/eca/llm_api.clj b/src/eca/llm_api.clj index 6a91dea7e..3283c7a65 100644 --- a/src/eca/llm_api.clj +++ b/src/eca/llm_api.clj @@ -3,7 +3,6 @@ [babashka.fs :as fs] [clojure.string :as string] [eca.config :as config] - [eca.features.login :as f.login] [eca.features.prompt :as f.prompt] [eca.llm-providers.anthropic :as llm-providers.anthropic] [eca.llm-providers.azure] @@ -353,7 +352,7 @@ (defn sync-or-async-prompt! [{:keys [provider model model-capabilities instructions user-messages config on-first-response-received on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated on-server-web-search - past-messages tools provider-auth db* messenger metrics variant cancelled? on-retry subagent?] + past-messages tools provider-auth provider-auth-fn variant cancelled? on-retry subagent?] :or {on-first-response-received identity on-message-received identity on-error identity @@ -421,81 +420,71 @@ stream? (if (not (nil? (:stream extra-payload))) (:stream extra-payload) true) - renew-token! (fn [] - (when db* - (f.login/maybe-renew-auth-token! - {:provider provider - :on-error (fn [error-msg] - (logger/error logger-tag "Token renewal failed:" error-msg))} - {:db* db* :config config :messenger messenger :metrics metrics}))) - current-provider-auth (fn [] - (if db* - (get-in @db* [:auth provider]) - provider-auth))] + resolve-auth (or provider-auth-fn (constantly provider-auth))] (if (not stream?) (let [sync-prompt-with-retry* (fn sync-prompt-with-retry [attempt] - (renew-token!) - (loop [result (prompt! - {:sync? true - :provider provider - :model model - :model-capabilities model-capabilities - :instructions instructions - :tools tools - :provider-auth (current-provider-auth) - :past-messages past-messages - :user-messages user-messages - :variant variant - :subagent? subagent? - :on-error on-error-wrapper - :config config})] - (let [{:keys [error output-text reason-text reasoning-content tools-to-call call-tools-fn reason-id usage]} result] - (if error - (maybe-retry error attempt on-error-wrapper sync-prompt-with-retry) - (do - (when reason-text - (on-reason-wrapper {:status :started :id reason-id}) - (on-reason-wrapper {:status :thinking :id reason-id :text reason-text}) - (on-reason-wrapper {:status :finished - :id reason-id - :delta-reasoning? (some? reasoning-content)})) - (on-message-received-wrapper {:type :text :text output-text}) - (some-> usage (on-usage-updated)) - (if-let [new-result (when (seq tools-to-call) - (doseq [tool-to-call tools-to-call] - (on-prepare-tool-call tool-to-call)) - (call-tools-fn on-tools-called))] - (recur new-result) - (on-message-received-wrapper {:type :finish :finish-reason "stop"})))))))] + (let [current-auth (resolve-auth)] + (loop [result (prompt! + {:sync? true + :provider provider + :model model + :model-capabilities model-capabilities + :instructions instructions + :tools tools + :provider-auth current-auth + :past-messages past-messages + :user-messages user-messages + :variant variant + :subagent? subagent? + :on-error on-error-wrapper + :config config})] + (let [{:keys [error output-text reason-text reasoning-content tools-to-call call-tools-fn reason-id usage]} result] + (if error + (maybe-retry error attempt on-error-wrapper sync-prompt-with-retry) + (do + (when reason-text + (on-reason-wrapper {:status :started :id reason-id}) + (on-reason-wrapper {:status :thinking :id reason-id :text reason-text}) + (on-reason-wrapper {:status :finished + :id reason-id + :delta-reasoning? (some? reasoning-content)})) + (on-message-received-wrapper {:type :text :text output-text}) + (some-> usage (on-usage-updated)) + (if-let [new-result (when (seq tools-to-call) + (doseq [tool-to-call tools-to-call] + (on-prepare-tool-call tool-to-call)) + (call-tools-fn on-tools-called))] + (recur new-result) + (on-message-received-wrapper {:type :finish :finish-reason "stop"}))))))))] (sync-prompt-with-retry* 0)) (let [async-prompt-with-retry* (fn async-prompt-with-retry [attempt] - (renew-token!) - (prompt! - {:sync? false - :provider provider - :model model - :model-capabilities model-capabilities - :instructions instructions - :tools tools - :provider-auth (current-provider-auth) - :past-messages past-messages - :user-messages user-messages - :variant variant - :subagent? subagent? - :cancelled? cancelled? - :on-message-received on-message-received-wrapper - :on-prepare-tool-call on-prepare-tool-call-wrapper - :on-tools-called on-tools-called - :on-usage-updated on-usage-updated - :on-server-web-search on-server-web-search-wrapper - :on-reason on-reason-wrapper - :on-error (fn [error-data] - (if (:silent? (ex-data (:exception error-data))) - (on-error-wrapper error-data) - (maybe-retry error-data attempt on-error-wrapper async-prompt-with-retry))) - :config config}))] + (let [current-auth (resolve-auth)] + (prompt! + {:sync? false + :provider provider + :model model + :model-capabilities model-capabilities + :instructions instructions + :tools tools + :provider-auth current-auth + :past-messages past-messages + :user-messages user-messages + :variant variant + :subagent? subagent? + :cancelled? cancelled? + :on-message-received on-message-received-wrapper + :on-prepare-tool-call on-prepare-tool-call-wrapper + :on-tools-called on-tools-called + :on-usage-updated on-usage-updated + :on-server-web-search on-server-web-search-wrapper + :on-reason on-reason-wrapper + :on-error (fn [error-data] + (if (:silent? (ex-data (:exception error-data))) + (on-error-wrapper error-data) + (maybe-retry error-data attempt on-error-wrapper async-prompt-with-retry))) + :config config})))] (async-prompt-with-retry* 0))))) (defn sync-prompt! From 1ddcb617409b8be620d90afe87799722fec58d67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:22:16 +0000 Subject: [PATCH 4/5] Fix rewrite-test: adapt to provider-auth-fn interface Agent-Logs-Url: https://github.com/itkonen/eca/sessions/9853614c-f3c8-4487-b99f-bd0d60a3d985 Co-authored-by: itkonen <37696708+itkonen@users.noreply.github.com> --- test/eca/features/rewrite_test.clj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/eca/features/rewrite_test.clj b/test/eca/features/rewrite_test.clj index 232752966..bca6e1799 100644 --- a/test/eca/features/rewrite_test.clj +++ b/test/eca/features/rewrite_test.clj @@ -154,10 +154,14 @@ (swap! (h/db*) assoc :models {"google/gemini-dev" {:reason? true}}) (let [renew-called* (atom nil) api-opts* (atom nil) + auth-result* (atom nil) resp (with-redefs [llm-api/sync-or-async-prompt! (fn [opts] (reset! api-opts* opts) + ;; Invoke provider-auth-fn to verify renewal is triggered and auth is returned + (when-let [auth-fn (:provider-auth-fn opts)] + (reset! auth-result* (auth-fn))) ((:on-first-response-received opts) {:type :text}) ((:on-message-received opts) {:type :finish})) f.prompt/build-rewrite-instructions (constantly "INSTR") @@ -174,4 +178,4 @@ (is (= "google" @renew-called*)) (is (= "google" (:provider @api-opts*))) (is (= "gemini-dev" (:model @api-opts*))) - (is (= {:token "abc"} (:provider-auth @api-opts*)))))) + (is (= {:token "abc"} @auth-result*))))) From 69f745a4728419437b6dfb9b09c80f34fda5df4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:46:05 +0000 Subject: [PATCH 5/5] Add provider-auth-fn test coverage for sync and async retry paths Agent-Logs-Url: https://github.com/itkonen/eca/sessions/545a12ee-f2c6-4cdb-923d-9e02ab7d0520 Co-authored-by: itkonen <37696708+itkonen@users.noreply.github.com> --- test/eca/llm_api_test.clj | 85 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/test/eca/llm_api_test.clj b/test/eca/llm_api_test.clj index 906595242..23689934b 100644 --- a/test/eca/llm_api_test.clj +++ b/test/eca/llm_api_test.clj @@ -454,3 +454,88 @@ :on-message-received identity}))) (is (= 1 @attempt*)) (is (true? @on-error-called*))))) + +(deftest sync-provider-auth-fn-initial-request-test + (testing "provider-auth-fn is invoked before the initial sync attempt and its result is passed to prompt!" + (let [auth-fn-call-count* (atom 0) + captured-auth* (atom nil) + fresh-auth {:api-key "fresh-token"}] + (with-redefs [eca.llm-api/prompt! (fn [opts] + (reset! captured-auth* (:provider-auth opts)) + {:output-text "done" + :usage {:input-tokens 1 :output-tokens 1}}) + eca.llm-api/sleep-with-cancel (fn [_ _] true)] + (llm-api/sync-or-async-prompt! + (-> (make-prompt-opts {:stream false :on-message-received identity}) + (dissoc :provider-auth) + (assoc :provider-auth-fn (fn [] (swap! auth-fn-call-count* inc) fresh-auth))))) + (is (= 1 @auth-fn-call-count*)) + (is (= fresh-auth @captured-auth*))))) + +(deftest sync-provider-auth-fn-called-per-retry-test + (testing "provider-auth-fn is invoked again on each sync retry attempt" + (let [attempt* (atom 0) + auth-fn-call-count* (atom 0) + captured-auths* (atom [])] + (with-redefs [eca.llm-api/prompt! (fn [opts] + (let [n (swap! attempt* inc)] + (swap! captured-auths* conj (:provider-auth opts)) + (if (= 1 n) + {:error {:status 429 + :body "Rate limited" + :message "LLM response status: 429"}} + {:output-text "ok" + :usage {:input-tokens 1 :output-tokens 1}}))) + eca.llm-api/sleep-with-cancel (fn [_ cancelled?] (not (cancelled?)))] + (llm-api/sync-or-async-prompt! + (-> (make-prompt-opts {:stream false :on-message-received identity}) + (dissoc :provider-auth) + (assoc :provider-auth-fn (fn [] + (let [n (swap! auth-fn-call-count* inc)] + {:api-key (str "token-" n)})))))) + (is (= 2 @attempt*)) + (is (= 2 @auth-fn-call-count*)) + (is (= [{:api-key "token-1"} {:api-key "token-2"}] @captured-auths*))))) + +(deftest async-provider-auth-fn-initial-request-test + (testing "provider-auth-fn is invoked before the initial async attempt and its result is passed to prompt!" + (let [auth-fn-call-count* (atom 0) + captured-auth* (atom nil) + fresh-auth {:api-key "fresh-token"}] + (with-redefs [eca.llm-api/prompt! (fn [{:keys [on-message-received] :as opts}] + (reset! captured-auth* (:provider-auth opts)) + (on-message-received {:type :text :text "hi"}) + (on-message-received {:type :finish :finish-reason "stop"})) + eca.llm-api/sleep-with-cancel (fn [_ _] true)] + (llm-api/sync-or-async-prompt! + (-> (make-prompt-opts {:on-message-received identity}) + (dissoc :provider-auth) + (assoc :provider-auth-fn (fn [] (swap! auth-fn-call-count* inc) fresh-auth))))) + (is (= 1 @auth-fn-call-count*)) + (is (= fresh-auth @captured-auth*))))) + +(deftest async-provider-auth-fn-called-per-retry-test + (testing "provider-auth-fn is invoked again on each async retry attempt" + (let [attempt* (atom 0) + auth-fn-call-count* (atom 0) + captured-auths* (atom [])] + (with-redefs [eca.llm-api/prompt! (fn [{:keys [on-message-received on-error] :as opts}] + (let [n (swap! attempt* inc)] + (swap! captured-auths* conj (:provider-auth opts)) + (if (= 1 n) + (on-error {:status 503 + :body "Overloaded" + :message "LLM response status: 503"}) + (do + (on-message-received {:type :text :text "ok"}) + (on-message-received {:type :finish :finish-reason "stop"}))))) + eca.llm-api/sleep-with-cancel (fn [_ cancelled?] (not (cancelled?)))] + (llm-api/sync-or-async-prompt! + (-> (make-prompt-opts {:on-message-received identity}) + (dissoc :provider-auth) + (assoc :provider-auth-fn (fn [] + (let [n (swap! auth-fn-call-count* inc)] + {:api-key (str "token-" n)})))))) + (is (= 2 @attempt*)) + (is (= 2 @auth-fn-call-count*)) + (is (= [{:api-key "token-1"} {:api-key "token-2"}] @captured-auths*)))))