Skip to content
Merged
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 @@ -5,6 +5,7 @@
## 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
- Fix auth refresh propagation during streaming and `spawn_agent` tool calls, preventing mid-session failures.

## 0.124.1

Expand Down
21 changes: 14 additions & 7 deletions src/eca/features/chat/tool_calls.clj
Original file line number Diff line number Diff line change
Expand Up @@ -748,8 +748,8 @@
:details details
:summary summary
:progress-text (if (= name "spawn_agent")
"Waiting subagent"
"Calling tool")})))
"Waiting subagent"
"Calling tool")})))
(let [tool-call-state (get-tool-call-state @db* chat-id id)
{:keys [code text]} (:decision-reason tool-call-state)
effective-hook-continue (when hook-rejected? hook-continue)
Expand Down Expand Up @@ -834,8 +834,15 @@
nil))))
(do
(lifecycle/maybe-renew-auth-token chat-ctx)
(if-let [continue-fn (:continue-fn chat-ctx)]
(continue-fn all-tools user-messages)
{:tools all-tools
:new-messages (shared/messages-after-last-compact-marker
(get-in @db* [:chats chat-id :messages]))}))))))))))
(let [provider-auth (get-in @db* [:auth (:provider chat-ctx)])
[_ fresh-api-key] (llm-util/provider-api-key (:provider chat-ctx)
provider-auth
config)
result (if-let [continue-fn (:continue-fn chat-ctx)]
(continue-fn all-tools user-messages)
{:tools all-tools
:new-messages (get-in @db* [:chats chat-id :messages])})]
(when result
(assoc result
:fresh-api-key fresh-api-key
:provider-auth provider-auth))))))))))))
23 changes: 13 additions & 10 deletions src/eca/features/login.clj
Original file line number Diff line number Diff line change
Expand Up @@ -91,24 +91,27 @@

(defn maybe-renew-auth-token! [{:keys [provider on-renewing on-error]} ctx]
(when-let [expires-at (get-in @(:db* ctx) [:auth provider :expires-at])]
(when (<= (long expires-at) (quot (System/currentTimeMillis) 1000))
;; Renew 60 seconds before expiration to avoid race between check and request
(when (<= (long expires-at) (+ 60 (quot (System/currentTimeMillis) 1000)))
(when on-renewing
(on-renewing))
(renew-auth! provider ctx
{:on-error on-error}))))

(defn login-done! [{:keys [chat-id db* messenger metrics provider send-msg!]}
& {:keys [silent?]
:or {silent? false}}]
& {:keys [silent? skip-models-sync?]
:or {silent? false
skip-models-sync? false}}]
(when (get-in @db* [:auth provider])
(db/update-global-cache! @db* metrics))
(models/sync-models! db*
(config/all @db*) ;; force get updated config
(fn [new-models]
(messenger/config-updated
messenger
{:chat
{:models (sort (keys new-models))}})))
(when-not skip-models-sync?
(models/sync-models! db*
(config/all @db*) ;; force get updated config
(fn [new-models]
(messenger/config-updated
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-not silent?
Expand Down
6 changes: 3 additions & 3 deletions src/eca/llm_providers/anthropic.clj
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@
:full-name (:name content-block)
:arguments (json/parse-string (:input-json content-block))}))
(vals @content-block*))]
(when-let [{:keys [new-messages tools]} (on-tools-called tool-calls)]
(when-let [{:keys [new-messages tools fresh-api-key]} (on-tools-called tool-calls)]
(let [messages (-> new-messages
group-parallel-tool-calls
(normalize-messages supports-image?)
Expand All @@ -460,7 +460,7 @@
:messages messages
:tools (->tools tools web-search))
:api-url api-url
:api-key api-key
:api-key (or fresh-api-key api-key)
:http-client http-client
:extra-headers extra-headers
:auth-type auth-type
Expand Down Expand Up @@ -698,4 +698,4 @@
:refresh-token refresh-token
:api-key access-token
:expires-at expires-at})
(f.login/login-done! ctx :silent? true)))
(f.login/login-done! ctx :silent? true :skip-models-sync? true)))
2 changes: 1 addition & 1 deletion src/eca/llm_providers/copilot.clj
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,4 @@
{:keys [api-key expires-at]} (oauth-renew-token access-token)]
(swap! db* update-in [:auth provider] merge {:api-key api-key
:expires-at expires-at})
(f.login/login-done! ctx :silent? true)))
(f.login/login-done! ctx :silent? true :skip-models-sync? true)))
11 changes: 6 additions & 5 deletions src/eca/llm_providers/openai.clj
Original file line number Diff line number Diff line change
Expand Up @@ -276,17 +276,18 @@
:output-tokens (-> response :usage :output_tokens)
:input-cache-read-tokens input-cache-read-tokens}))
(if (seq tool-calls)
(when-let [{:keys [new-messages tools]} (on-tools-called tool-calls)]
(reset! tool-call-by-item-id* {})
(when-let [{:keys [new-messages tools fresh-api-key provider-auth]} (on-tools-called tool-calls)]
(doseq [tool-call tool-calls]
(swap! tool-call-by-item-id* dissoc (:item-id tool-call)))
(base-responses-request!
{:rid (llm-util/gen-rid)
:body (assoc body
:input (normalize-messages new-messages supports-image?)
:tools (->tools tools web-search codex?))
:api-url api-url
:url-relative-path url-relative-path
:api-key api-key
:account-id account-id
:api-key (or fresh-api-key api-key)
:account-id (or (:account-id provider-auth) account-id)
:http-client http-client
:extra-headers extra-headers
:auth-type auth-type
Expand Down Expand Up @@ -490,4 +491,4 @@
:api-key access-token
:account-id new-account-id
:expires-at expires-at})
(f.login/login-done! ctx :silent? true)))
(f.login/login-done! ctx :silent? true :skip-models-sync? true)))
4 changes: 2 additions & 2 deletions src/eca/llm_providers/openai_chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@
k))
tool-calls))
on-tools-called-wrapper (fn on-tools-called-wrapper [tools-to-call on-tools-called handle-response]
(when-let [{:keys [new-messages tools]} (on-tools-called tools-to-call)]
(when-let [{:keys [new-messages tools fresh-api-key]} (on-tools-called tools-to-call)]
(let [pruned-messages (prune-history new-messages reasoning-history)
new-messages-list (vec (concat
system-messages
Expand All @@ -516,7 +516,7 @@
:extra-headers extra-headers
:http-client http-client
:api-url api-url
:api-key api-key
:api-key (or fresh-api-key api-key)
:url-relative-path url-relative-path
:on-error wrapped-on-error
:on-stream (when stream? (fn [event data] (handle-response event data tool-calls*)))}))))
Expand Down
56 changes: 56 additions & 0 deletions test/eca/features/chat/tool_calls_test.clj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
(ns eca.features.chat.tool-calls-test
(:require
[clojure.test :refer [deftest is testing]]
[eca.features.chat.lifecycle :as lifecycle]
[eca.features.chat.tool-calls :as tc]
[eca.features.hooks :as f.hooks]
[eca.features.tools :as f.tools]
Expand Down Expand Up @@ -276,3 +277,58 @@
:hook-rejected? false
:arguments-modified? true}
plan)))))))

(deftest on-tools-called!-returns-provider-auth-test
(testing "returns refreshed provider auth in result after token is renewed during tool execution"
;; Regression test for: auth captured in LLM provider closure at prompt start.
;; When a token expires mid-stream, maybe-renew-auth-token updates db*,
;; and on-tools-called! must return the refreshed auth so provider-specific
;; continuation metadata (not just the api key) can be reused.
(h/reset-components!)
(let [chat-id "test-chat"
provider "github-copilot"
renewed-provider-auth {:api-key "fresh-token-xyz"
:expires-at 9999999999}
db* (h/db*)
_ (swap! db* #(-> %
(assoc-in [:auth provider :api-key] "stale-token-abc")
(assoc-in [:chats chat-id :status] :running)
(assoc-in [:chats chat-id :messages] [])
(assoc-in [:chats chat-id :tool-calls "call-1" :status] :preparing)))
;; Pre-set tool call to :preparing state, as it would be after on-prepare-tool-call
;; fires during the streaming phase before on-tools-called! is invoked.
chat-ctx {:db* db*
:config (h/config)
:chat-id chat-id
:provider provider
:agent :default
:messenger (h/messenger)
:metrics (h/metrics)}
received-msgs* (atom "")
add-to-history! (fn [msg]
(swap! db* update-in [:chats chat-id :messages] (fnil conj []) msg))
tool-calls [{:id "call-1"
:full-name "eca__test_tool"
:arguments {}
:arguments-text "{}"}]
all-tools [{:name "test_tool"
:full-name "eca__test_tool"
:origin :eca
:server {:name "eca"}}]
expected-provider-auth (merge {:api-key "stale-token-abc"} renewed-provider-auth)]
(with-redefs [f.tools/all-tools (constantly all-tools)
f.tools/approval (constantly :allow)
f.hooks/trigger-if-matches! (fn [_ _ _ _ _] nil)
f.tools/call-tool! (fn [& _] {:contents [{:text "result" :type :text}]})
f.tools/tool-call-details-before-invocation (constantly nil)
f.tools/tool-call-details-after-invocation (constantly nil)
f.tools/tool-call-summary (constantly "Test tool")
lifecycle/maybe-renew-auth-token
(fn [ctx]
(swap! (:db* ctx) update-in [:auth provider] merge renewed-provider-auth))]
(let [result ((tc/on-tools-called! chat-ctx received-msgs* add-to-history! []) tool-calls)]
(is (= (:api-key expected-provider-auth) (:fresh-api-key result))
"fresh-api-key must be returned so the provider can use it in the recursive streaming call")
(is (= expected-provider-auth
(:provider-auth result))
"provider-auth must be returned so providers can reuse refreshed auth metadata"))))))
49 changes: 49 additions & 0 deletions test/eca/llm_providers/openai_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,55 @@
(is (> (:expires-at result) now-seconds)
"expires-at should be computed relative to current time"))))))

(deftest create-response-refreshes-account-id-after-tool-call-test
(testing "uses refreshed provider auth metadata after a long-running tool call"
(let [requests* (atom [])]
(with-redefs [llm-providers.openai/base-responses-request!
(fn [{:keys [api-key account-id on-stream] :as _opts}]
(swap! requests* conj {:api-key api-key
:account-id account-id})
(when (= 1 (count @requests*))
(on-stream "response.completed"
{:response {:output [{:type "function_call"
:id "item-1"
:call_id "call-1"
:name "eca__spawn_agent"
:arguments "{}"}]
:usage {:input_tokens 1
:output_tokens 1}}}))
:ok)]
(llm-providers.openai/create-response!
{:model "gpt-test"
:user-messages [{:role "user" :content [{:type :text :text "hi"}]}]
:instructions "ins"
:reason? false
:supports-image? false
:api-key "stale-token"
:api-url "http://localhost:1"
:past-messages []
:tools [{:full-name "eca__spawn_agent" :description "spawn" :parameters {:type "object"}}]
:web-search false
:extra-payload {}
:extra-headers nil
:auth-type :auth/oauth
:account-id "old-account"}
{:on-message-received (fn [_])
:on-error (fn [e] (throw (ex-info "err" e)))
:on-prepare-tool-call (fn [_])
:on-tools-called (fn [_]
{:new-messages []
:tools []
:fresh-api-key "fresh-token"
:provider-auth {:account-id "new-account"}})
:on-reason (fn [_])
:on-usage-updated (fn [_])
:on-server-web-search (fn [_])})
(is (= [{:api-key "stale-token"
:account-id "old-account"}
{:api-key "fresh-token"
:account-id "new-account"}]
@requests*))))))

(deftest ->normalize-messages-test
(testing "no previous history"
(is (match?
Expand Down
Loading