diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cd1066f..9f00c75fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Fix agent switching (e.g. "code" → "plan") not taking effect mid-conversation for OpenAI Responses API models (openai/gpt-5.3-codex, github-copilot/gpt-5.3-codex). The static system prompt cache is now keyed per agent and invalidated on agent change. + ## 0.126.0 - Chat titles now re-generate at the 3rd user message using full conversation context for more accurate titles. diff --git a/src/eca/features/chat.clj b/src/eca/features/chat.clj index a57e498b6..12071d970 100644 --- a/src/eca/features/chat.clj +++ b/src/eca/features/chat.clj @@ -612,6 +612,7 @@ :model-capabilities model-capabilities :user-messages user-messages :instructions instructions + :agent agent :past-messages (shared/messages-after-last-compact-marker (get-in @db* [:chats chat-id :messages] [])) :config config @@ -1009,14 +1010,17 @@ (f.context/agents-file-contexts db) (f.context/raw-contexts->refined contexts db)) repo-map* (delay (f.index/repo-map db config {:as-string? true})) - cached-static (get-in db [:chats chat-id :prompt-cache :static]) + prompt-cache (get-in db [:chats chat-id :prompt-cache]) + cached-static (when (= (:agent prompt-cache) agent) + (:static prompt-cache)) instructions (if cached-static {:static cached-static :dynamic (f.prompt/build-dynamic-instructions refined-contexts db)} (let [result (f.prompt/build-chat-instructions refined-contexts rules skills repo-map* agent config chat-id all-tools db)] - (swap! db* assoc-in [:chats chat-id :prompt-cache :static] (:static result)) + (swap! db* update-in [:chats chat-id :prompt-cache] + assoc :static (:static result) :agent agent) result)) image-contents (->> refined-contexts (filter #(= :image (:type %)))) diff --git a/src/eca/llm_api.clj b/src/eca/llm_api.clj index 7221df556..49cafd68d 100644 --- a/src/eca/llm_api.clj +++ b/src/eca/llm_api.clj @@ -167,7 +167,7 @@ merged))) (defn ^:private prompt! - [{:keys [provider model model-capabilities instructions user-messages config variant + [{:keys [provider model model-capabilities instructions user-messages config variant agent 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 sync? subagent? cancelled?] :or {on-error identity}}] @@ -203,6 +203,7 @@ (handler {:model real-model :instructions flat-instructions + :agent agent :user-messages user-messages :max-output-tokens max-output-tokens :reason? reason? @@ -254,6 +255,7 @@ extra-headers)) base-opts {:model real-model :instructions flat-instructions + :agent agent :user-messages user-messages :max-output-tokens max-output-tokens :reason? reason? @@ -326,6 +328,7 @@ (handler {:model real-model :instructions flat-instructions + :agent agent :user-messages user-messages :max-output-tokens max-output-tokens :web-search web-search @@ -353,7 +356,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 refresh-provider-auth-fn variant cancelled? on-retry subagent?] + past-messages tools provider-auth refresh-provider-auth-fn variant cancelled? on-retry subagent? agent] :or {on-first-response-received identity on-message-received identity on-error identity @@ -441,6 +444,7 @@ :model model :model-capabilities model-capabilities :instructions instructions + :agent agent :tools tools :provider-auth (fresh-provider-auth) :past-messages past-messages @@ -476,6 +480,7 @@ :model model :model-capabilities model-capabilities :instructions instructions + :agent agent :tools tools :provider-auth (fresh-provider-auth) :past-messages past-messages diff --git a/src/eca/llm_providers/openai.clj b/src/eca/llm_providers/openai.clj index b0394ec3d..ba4d0f638 100644 --- a/src/eca/llm_providers/openai.clj +++ b/src/eca/llm_providers/openai.clj @@ -152,7 +152,7 @@ tools) (and web-search (not codex?)) (conj {:type "web_search_preview"}))) -(defn create-response! [{:keys [model user-messages instructions reason? supports-image? api-key api-url url-relative-path +(defn create-response! [{:keys [model user-messages instructions agent reason? supports-image? api-key api-url url-relative-path max-output-tokens past-messages tools web-search extra-payload extra-headers auth-type account-id http-client]} {:keys [on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated on-server-web-search] :as callbacks}] (let [codex? (= :auth/oauth auth-type) @@ -165,7 +165,7 @@ :input (if codex? (concat [{:role "system" :content instructions}] input) input) - :prompt_cache_key (str (System/getProperty "user.name") "@ECA") + :prompt_cache_key (str (System/getProperty "user.name") "@ECA" (when agent (str "/" agent))) :instructions instructions :tools tools :include (when reason? diff --git a/test/eca/features/chat_test.clj b/test/eca/features/chat_test.clj index 4731ade88..93a4f9ee3 100644 --- a/test/eca/features/chat_test.clj +++ b/test/eca/features/chat_test.clj @@ -927,3 +927,31 @@ (h/messages))))))) + +(deftest prompt-cache-agent-switch-test + (testing "agent switching invalidates static prompt cache" + (h/reset-components!) + (h/config! {:agent {"code" {:mode "primary"} "plan" {:mode "primary"}}}) + (let [build-calls* (atom 0) + api-mock (fn [{:keys [on-first-response-received on-message-received]}] + (on-first-response-received {:type :text :text "ok"}) + (on-message-received {:type :text :text "ok"}) + (on-message-received {:type :finish})) + base-mocks {:all-tools-mock (constantly []) + :api-mock api-mock + :call-tool-mock (constantly nil)}] + (with-redefs [f.prompt/build-chat-instructions + (fn [& _] + (swap! build-calls* inc) + {:static (str "static-" @build-calls*) + :dynamic "dynamic"})] + (let [{:keys [chat-id]} (prompt! {:message "Hello" :agent "code"} base-mocks)] + (is (= 1 @build-calls*) "First call should build instructions") + + (h/reset-messenger!) + (prompt! {:message "Hello again" :chat-id chat-id :agent "code"} base-mocks) + (is (= 1 @build-calls*) "Same agent reuses cached static instructions") + + (h/reset-messenger!) + (prompt! {:message "Switch" :chat-id chat-id :agent "plan"} base-mocks) + (is (= 2 @build-calls*) "Different agent should rebuild instructions")))))) diff --git a/test/eca/llm_providers/openai_test.clj b/test/eca/llm_providers/openai_test.clj index 901200930..3c68b8df5 100644 --- a/test/eca/llm_providers/openai_test.clj +++ b/test/eca/llm_providers/openai_test.clj @@ -1,5 +1,6 @@ (ns eca.llm-providers.openai-test (:require + [clojure.string :as string] [clojure.test :refer [deftest is testing]] [eca.client-test-helpers :refer [with-client-proxied]] [eca.llm-providers.openai :as llm-providers.openai] @@ -437,3 +438,30 @@ :arguments {"command" "ls"}}] (first @tools-called*))) (is (= 2 (count @requests*))))))) + +(deftest create-response-prompt-cache-key-agent-test + (testing "prompt_cache_key includes agent suffix when agent is provided" + (let [body* (atom nil)] + (with-redefs [llm-providers.openai/base-responses-request! + (fn [{:keys [body on-stream]}] + (reset! body* body) + (on-stream "response.completed" + {:response {:output [] :usage {:input_tokens 1 :output_tokens 1}}}))] + (llm-providers.openai/create-response! + (assoc (base-provider-params) :agent "plan") + (base-callbacks {})) + (is (string/ends-with? (:prompt_cache_key @body*) "/plan") + "prompt_cache_key should end with /plan")))) + + (testing "prompt_cache_key has no agent suffix when agent is nil" + (let [body* (atom nil)] + (with-redefs [llm-providers.openai/base-responses-request! + (fn [{:keys [body on-stream]}] + (reset! body* body) + (on-stream "response.completed" + {:response {:output [] :usage {:input_tokens 1 :output_tokens 1}}}))] + (llm-providers.openai/create-response! + (base-provider-params) + (base-callbacks {})) + (is (not (string/includes? (:prompt_cache_key @body*) "/")) + "prompt_cache_key should not contain / when agent is nil")))))