Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 %))))
Expand Down
9 changes: 7 additions & 2 deletions src/eca/llm_api.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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}}]
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/eca/llm_providers/openai.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)))
Comment thread
itkonen marked this conversation as resolved.
:instructions instructions
:tools tools
:include (when reason?
Expand Down
28 changes: 28 additions & 0 deletions test/eca/features/chat_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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"))))))
28 changes: 28 additions & 0 deletions test/eca/llm_providers/openai_test.clj
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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")))))
Comment on lines +457 to +467
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check if it is even possible for the agent name to contain a slash or be nil. I think there always should be an agent specified (at least "plan" and "code" shipped by ECA). And we need to guarantee that the agent name does not contain a slash. I assume this is already restricted when defining an agent with an markdown file, but the json config could contain a slash. Anyway, I think this rather a marginal concern and should be dealt with a lightweight agent name validator.

Loading