Skip to content

Commit a48fc55

Browse files
ericdalloeca-agent
andcommitted
Fix stale system prompt after switching agent mid-chat #411
Scope the chat-level prompt cache and OpenAI Responses prompt_cache_key by the active agent so a mid-chat agent switch rebuilds the static system prompt instead of reusing the first agent's cached one. 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
1 parent 5d8ff3b commit a48fc55

6 files changed

Lines changed: 113 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Fix stale system prompt being reused after switching agent mid-chat by scoping the chat-level prompt cache and the OpenAI Responses `prompt_cache_key` per active agent. #411
56
- Improve chat title quality on 3rd-message retitle by filtering tool calls, tool results, reasoning and flag entries from the history passed to the title LLM, and by respecting the last compact marker.
67

78
## 0.128.0

src/eca/features/chat.clj

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@
6060
(def ^:private cleared-raw-content
6161
[{:type "text" :text "[content cleared to reduce context size]"}])
6262

63+
(defn ^:private prompt-cache-key
64+
"Builds a provider-agnostic prompt cache key.
65+
OpenAI's Responses API sends it as `prompt_cache_key`; other providers
66+
currently ignore it. Scoping by agent prevents cache hits across
67+
agent switches within the same user session."
68+
[agent]
69+
(str (System/getProperty "user.name") "@ECA"
70+
(when (not-empty agent) (str "/" agent))))
71+
6372
(defn ^:private prune-tool-results!
6473
"Prunes old tool result content from chat history to reduce context size.
6574
Walks messages backwards, protecting the most recent tool outputs up to
@@ -619,6 +628,7 @@
619628
(lifecycle/maybe-renew-auth-token chat-ctx)
620629
(get-in @db* [:auth provider]))
621630
:variant (:variant chat-ctx)
631+
:prompt-cache-key (prompt-cache-key agent)
622632
:subagent? (some? (get-in @db* [:chats chat-id :subagent]))
623633
:cancelled? (fn []
624634
(let [chat (get-in @db* [:chats chat-id])]
@@ -1005,14 +1015,17 @@
10051015
(f.context/agents-file-contexts db)
10061016
(f.context/raw-contexts->refined contexts db))
10071017
repo-map* (delay (f.index/repo-map db config {:as-string? true}))
1008-
cached-static (get-in db [:chats chat-id :prompt-cache :static])
1018+
prompt-cache (get-in db [:chats chat-id :prompt-cache])
1019+
cached-static (when (= (:agent prompt-cache) agent)
1020+
(:static prompt-cache))
10091021
instructions (if cached-static
10101022
{:static cached-static
10111023
:dynamic (f.prompt/build-dynamic-instructions refined-contexts db)}
10121024
(let [result (f.prompt/build-chat-instructions
10131025
refined-contexts rules skills repo-map*
10141026
agent config chat-id all-tools db)]
1015-
(swap! db* assoc-in [:chats chat-id :prompt-cache :static] (:static result))
1027+
(swap! db* update-in [:chats chat-id :prompt-cache]
1028+
assoc :static (:static result) :agent agent)
10161029
result))
10171030
image-contents (->> refined-contexts
10181031
(filter #(= :image (:type %))))

src/eca/llm_api.clj

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@
172172
(defn ^:private prompt!
173173
[{:keys [provider model model-capabilities instructions user-messages config variant
174174
on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated on-server-web-search
175-
past-messages tools provider-auth sync? subagent? cancelled?]
175+
past-messages tools provider-auth sync? subagent? cancelled? prompt-cache-key]
176176
:or {on-error identity}}]
177177
(let [real-model (real-model-name model model-capabilities)
178178
tools (when (:tools model-capabilities) tools)
@@ -220,7 +220,8 @@
220220
:api-url api-url
221221
:api-key api-key
222222
:auth-type auth-type
223-
:account-id (:account-id provider-auth)}
223+
:account-id (:account-id provider-auth)
224+
:prompt-cache-key prompt-cache-key}
224225
callbacks)
225226

226227
(= "anthropic" provider)
@@ -267,7 +268,8 @@
267268
extra-payload)
268269
:reasoning-history reasoning-history
269270
:api-url api-url
270-
:api-key api-key}]
271+
:api-key api-key
272+
:prompt-cache-key prompt-cache-key}]
271273
(if (= :openai-responses (:api api-handler))
272274
(handler
273275
(assoc base-opts
@@ -356,7 +358,7 @@
356358
(defn sync-or-async-prompt!
357359
[{:keys [provider model model-capabilities instructions user-messages config on-first-response-received
358360
on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated on-server-web-search
359-
past-messages tools provider-auth refresh-provider-auth-fn variant cancelled? on-retry subagent?]
361+
past-messages tools provider-auth refresh-provider-auth-fn variant cancelled? on-retry subagent? prompt-cache-key]
360362
:or {on-first-response-received identity
361363
on-message-received identity
362364
on-error identity
@@ -450,6 +452,7 @@
450452
:user-messages user-messages
451453
:variant variant
452454
:subagent? subagent?
455+
:prompt-cache-key prompt-cache-key
453456
:on-error on-error-wrapper
454457
:config config})]
455458
(let [{:keys [error output-text reason-text reasoning-content tools-to-call call-tools-fn reason-id usage]} result]
@@ -485,6 +488,7 @@
485488
:user-messages user-messages
486489
:variant variant
487490
:subagent? subagent?
491+
:prompt-cache-key prompt-cache-key
488492
:cancelled? cancelled?
489493
:on-message-received on-message-received-wrapper
490494
:on-prepare-tool-call on-prepare-tool-call-wrapper

src/eca/llm_providers/openai.clj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@
153153
(and web-search (not codex?)) (conj {:type "web_search_preview"})))
154154

155155
(defn create-response! [{:keys [model user-messages instructions reason? supports-image? api-key api-url url-relative-path
156-
max-output-tokens past-messages tools web-search extra-payload extra-headers auth-type account-id http-client]}
156+
max-output-tokens past-messages tools web-search extra-payload extra-headers auth-type account-id http-client
157+
prompt-cache-key]}
157158
{:keys [on-message-received on-error on-prepare-tool-call on-tools-called on-reason on-usage-updated on-server-web-search] :as callbacks}]
158159
(let [codex? (= :auth/oauth auth-type)
159160
input (concat (normalize-messages past-messages supports-image?)
@@ -165,7 +166,8 @@
165166
:input (if codex?
166167
(concat [{:role "system" :content instructions}] input)
167168
input)
168-
:prompt_cache_key (str (System/getProperty "user.name") "@ECA")
169+
:prompt_cache_key (or prompt-cache-key
170+
(str (System/getProperty "user.name") "@ECA"))
169171
:instructions instructions
170172
:tools tools
171173
:include (when reason?

test/eca/features/chat_test.clj

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,4 +979,55 @@
979979
:role "assistant"}]}
980980
(h/messages)))))))
981981

982+
(deftest prompt-cache-agent-switch-test
983+
(testing "Static prompt cache is rebuilt when switching agents within the same chat"
984+
(h/reset-components!)
985+
(let [build-calls* (atom 0)
986+
real-build f.prompt/build-chat-instructions]
987+
(with-redefs [f.prompt/build-chat-instructions
988+
(fn [& args]
989+
(swap! build-calls* inc)
990+
(apply real-build args))
991+
;; Test config doesn't populate :agent, so validate-agent-name
992+
;; would fall back to "code" for every input. Short-circuit it
993+
;; so the test can actually exercise an agent switch.
994+
config/validate-agent-name (fn [agent-name _config] agent-name)]
995+
(let [mocks {:all-tools-mock (constantly [])
996+
:api-mock (fn [{:keys [on-message-received]}]
997+
(on-message-received {:type :finish}))}
998+
{:keys [chat-id]} (prompt! {:message "Hi" :agent "code"} mocks)]
999+
(is (= 1 @build-calls*)
1000+
"First prompt should build the static instructions once")
1001+
(h/reset-messenger!)
1002+
(prompt! {:message "Still code" :chat-id chat-id :agent "code"} mocks)
1003+
(is (= 1 @build-calls*)
1004+
"Second prompt with the same agent should reuse cached instructions")
1005+
(h/reset-messenger!)
1006+
(prompt! {:message "Switch to plan" :chat-id chat-id :agent "plan"} mocks)
1007+
(is (= 2 @build-calls*)
1008+
"Switching agent should invalidate the cache and rebuild instructions")
1009+
(h/reset-messenger!)
1010+
(prompt! {:message "Back to code" :chat-id chat-id :agent "code"} mocks)
1011+
(is (= 3 @build-calls*)
1012+
"Switching back to the first agent should also trigger a rebuild"))))))
1013+
1014+
(deftest prompt-cache-key-includes-agent-test
1015+
(testing "sync-or-async-prompt! receives prompt-cache-key scoped by active agent"
1016+
(h/reset-components!)
1017+
(with-redefs [config/validate-agent-name (fn [agent-name _config] agent-name)]
1018+
(let [captured* (atom [])
1019+
mocks {:all-tools-mock (constantly [])
1020+
:api-mock (fn [{:keys [on-message-received] :as params}]
1021+
(swap! captured* conj (:prompt-cache-key params))
1022+
(on-message-received {:type :finish}))}
1023+
{:keys [chat-id]} (prompt! {:message "hi" :agent "code"} mocks)]
1024+
(h/reset-messenger!)
1025+
(prompt! {:message "hello" :chat-id chat-id :agent "plan"} mocks)
1026+
(is (= 2 (count @captured*)))
1027+
(is (every? some? @captured*))
1028+
(is (string/ends-with? (first @captured*) "/code")
1029+
"First prompt's cache key should be suffixed by /code")
1030+
(is (string/ends-with? (second @captured*) "/plan")
1031+
"Second prompt's cache key should be suffixed by /plan")))))
1032+
9821033

test/eca/llm_providers/openai_test.clj

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,40 @@
291291
(first @tools-called*)))
292292
(is (= 2 (count @requests*)))))))
293293

294+
(deftest create-response-prompt-cache-key-test
295+
(testing "prompt_cache_key uses the provided :prompt-cache-key verbatim"
296+
(let [requests* (atom [])]
297+
(with-redefs [llm-providers.openai/base-responses-request!
298+
(fn [{:keys [on-stream] :as opts}]
299+
(swap! requests* conj opts)
300+
(on-stream "response.completed"
301+
{:response {:output []
302+
:usage {:input_tokens 0 :output_tokens 0}
303+
:status "completed"}}))]
304+
(llm-providers.openai/create-response!
305+
(assoc (base-provider-params) :prompt-cache-key "alice@ECA/plan")
306+
(base-callbacks {}))
307+
(is (= 1 (count @requests*)))
308+
(is (= "alice@ECA/plan"
309+
(get-in (first @requests*) [:body :prompt_cache_key]))
310+
"Body should pass the caller-supplied cache key unchanged"))))
311+
(testing "prompt_cache_key falls back to $USER@ECA when :prompt-cache-key is absent"
312+
(let [requests* (atom [])]
313+
(with-redefs [llm-providers.openai/base-responses-request!
314+
(fn [{:keys [on-stream] :as opts}]
315+
(swap! requests* conj opts)
316+
(on-stream "response.completed"
317+
{:response {:output []
318+
:usage {:input_tokens 0 :output_tokens 0}
319+
:status "completed"}}))]
320+
(llm-providers.openai/create-response!
321+
(base-provider-params)
322+
(base-callbacks {}))
323+
(is (= 1 (count @requests*)))
324+
(is (= (str (System/getProperty "user.name") "@ECA")
325+
(get-in (first @requests*) [:body :prompt_cache_key]))
326+
"Body should use the default $USER@ECA key when no cache key is provided")))))
327+
294328
(deftest create-response-tool-calls-fallback-via-atom-test
295329
(testing "empty output in response.completed still triggers on-tools-called via atom fallback"
296330
(let [tools-called* (atom [])

0 commit comments

Comments
 (0)