Skip to content

Commit fe293ba

Browse files
authored
Merge pull request #509 from editor-code-assistant/rework-system-prompt-handling
Rework system-prompt cache identity and /prompt-show output`
2 parents 7800158 + 262963d commit fe293ba

13 files changed

Lines changed: 502 additions & 171 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- Add message pagination over a shared cursor core: new JSON-RPC `chat/history` method and optional `limit`/`before`/`after` window params on `chat/open`, plus opt-in pagination on remote HTTP `GET /api/v1/chats/:id`. Opaque cursors with a `lastCompaction` sentinel; meta exposes before/after/compaction cursors.
2121
- Support declaring image input for custom models via a per-model `imageInput` config flag; openai-chat now round-trips tool-result images. (#503)
2222
- Bedrock: honor Anthropic thinking variants (effort + adaptive thinking) for Claude models, fixing Claude 4.7+ failing with `thinking.type.enabled` not supported. (#502)
23+
- Reworked system-prompt handling: fix stale static cache, quote-safe context attributes, dedicated workspace-roots/MCP sections, content-named context headings (`## Context`/`## MCP Resources`), and a read-only, reorganized `/prompt-show`.
2324

2425
## 0.140.1
2526

‎integration-test/integration/chat/commands_test.clj‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,11 @@
8080
resp))
8181

8282
(match-content chat-id "user" {:type "text" :text "/prompt-show\n"})
83-
(match-content chat-id "system" {:type "text" :text (m/pred #(and (string/includes? % "You are ECA")
84-
(not (string/includes? % ":static"))
85-
(not (string/includes? % ":dynamic"))))})
83+
(match-content chat-id "system" {:type "text" :text (m/pred #(and (string/includes? % "# Instructions (System prompt)")
84+
(string/includes? % "You are ECA")
85+
(not (string/includes? % "# Chat (User prompt)"))
86+
(not (string/includes? % "/prompt-show"))
87+
(string/includes? % "Tool schemas are sent separately")))})
8688
(match-content chat-id "system" {:type "progress" :state "finished"}))))
8789

8890
(deftest mcp-prompts

‎resources/prompts/additional_system_info.md‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,3 @@ OS: {{osName}}
44
Default shell: {{shell}}
55
User: {{userName}}
66
Home directory: {{homeDir}}
7-
Workspaces: {{workspaceRoots}}
8-
9-
**Path Resolution & Context:**
10-
*Workspaces:* Directories containing code or data relevant to this session.
11-
*Rule:* Use Workspaces as the base to resolve relative paths into absolute paths when required by tools.
12-

‎resources/prompts/code_agent.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ For each file, give a short description of what needs to be edited, then use the
1313
{% if toolEnabled_eca__editor_diagnostics %}
1414
After finishing your changes, use eca__editor_diagnostics to check for diagnostics, making sure you didn't introduce any errors or warnings.
1515
{% endif %}
16-
1716
## Communication
1817

1918
The chat is markdown mode.
@@ -27,9 +26,9 @@ You have tools at your disposal to solve the coding task. Follow these rules reg
2726
2. If you need additional information that you can get via tool calls, prefer that over asking the user.
2827
3. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
2928
4. You have the capability to call multiple tools in a single response, batch your tool calls together for optimal performance.
30-
3129
{% if toolEnabled_eca__task %}
3230
## Task Tracking
31+
3332
You have access to the `eca__task` tool for task management.
3433

3534
Use `eca__task` as the canonical task list when you need to plan and track non-trivial, multi-step execution (e.g., multiple tasks, dependencies, or iterative debugging), or when the user explicitly asks for a plan/todo list. Skip it for a single small action or purely informational replies.

‎src/eca/cache.clj‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[babashka.fs :as fs]
55
[clojure.java.io :as io]
66
[clojure.string :as string]
7+
[eca.digest :as digest]
78
[eca.logger :as logger])
89
(:import
910
[java.io File]))
@@ -53,11 +54,10 @@
5354
Order-independent: the same set of folders always yields the same hash."
5455
[workspaces uri->filename-fn]
5556
(let [joined (string/join ":" (sorted-workspace-paths workspaces uri->filename-fn))
56-
md (java.security.MessageDigest/getInstance "SHA-256")
57-
digest (.digest (doto md (.update (.getBytes joined "UTF-8"))))
57+
digest-bytes (digest/sha-256-bytes joined)
5858
encoder (-> (java.util.Base64/getUrlEncoder)
5959
(.withoutPadding))
60-
key (.encodeToString encoder digest)]
60+
key (.encodeToString encoder digest-bytes)]
6161
(subs key 0 (min 8 (count key)))))
6262

6363
(def ^:private logger-tag "[CACHE]")

‎src/eca/digest.clj‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
(ns eca.digest
2+
(:import
3+
[java.nio.charset StandardCharsets]
4+
[java.security MessageDigest]))
5+
6+
(set! *warn-on-reflection* true)
7+
8+
(defn sha-256-bytes ^bytes [^String s]
9+
(-> (MessageDigest/getInstance "SHA-256")
10+
(.digest (.getBytes s StandardCharsets/UTF_8))))
11+
12+
(defn sha-256-hex ^String [^String s]
13+
(->> (sha-256-bytes s)
14+
(map #(format "%02x" (bit-and % 0xff)))
15+
(apply str)))

‎src/eca/features/chat.clj‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
[clojure.java.io :as io]
55
[clojure.set :as set]
66
[clojure.string :as string]
7+
[eca.cache :as cache]
78
[eca.config :as config]
89
[eca.db :as db]
10+
[eca.digest :as digest]
911
[eca.features.background-tasks :as bg]
1012
[eca.features.chat.history :as history]
1113
[eca.features.chat.lifecycle :as lifecycle]
@@ -64,6 +66,29 @@
6466
(str (System/getProperty "user.name") "@ECA"
6567
(when (not-empty agent) (str "/" agent))))
6668

69+
(defn ^:private static-prompt-cache-signature
70+
"SHA-256 cache identity for static prompt reuse."
71+
[refined-contexts static-rules path-scoped-rules skills agent config chat-id all-tools db]
72+
(let [static-contexts (vec (filter f.prompt/static-prompt-context? refined-contexts))]
73+
(digest/sha-256-hex
74+
(pr-str
75+
{:agent agent
76+
:chat-prompt-template (f.prompt/eca-chat-prompt agent config chat-id db)
77+
:workspace-roots (mapv (comp shared/uri->filename :uri) (:workspace-folders db))
78+
:environment {:os-name (str (System/getProperty "os.name") " " (System/getProperty "os.version"))
79+
:shell (or (System/getenv "SHELL") (System/getenv "ComSpec"))
80+
:user-name (System/getProperty "user.name")
81+
:home-dir (cache/user-home)}
82+
:is-subagent (boolean (get-in db [:chats chat-id :subagent]))
83+
:startup-context (get-in db [:chats chat-id :startup-context])
84+
:static-contexts static-contexts
85+
:repo-map (when (some #(= :repoMap (:type %)) static-contexts)
86+
:present)
87+
:static-rules (mapv #(select-keys % [:id :name :scope :content]) static-rules)
88+
:path-scoped-rules (mapv #(select-keys % [:id :name :scope :workspace-root :paths :enforce]) path-scoped-rules)
89+
:skills (mapv #(select-keys % [:name :description]) skills)
90+
:tools (sort (map :full-name all-tools))}))))
91+
6792
(defn ^:private prune-tool-results!
6893
"Prunes old tool result content from chat history to reduce context size.
6994
Walks messages backwards, protecting the most recent tool outputs up to
@@ -1451,16 +1476,21 @@
14511476
agent)))))
14521477
repo-map* (delay (f.index/repo-map db config {:as-string? true}))
14531478
prompt-cache (get-in db [:chats chat-id :prompt-cache])
1479+
static-signature (static-prompt-cache-signature
1480+
refined-contexts static-rules path-scoped-rules skills
1481+
agent config chat-id all-tools db)
14541482
instructions (if (and prompt-cache
14551483
(= (:agent prompt-cache) agent)
1456-
(= (:model prompt-cache) full-model))
1484+
(= (:model prompt-cache) full-model)
1485+
(= (:static-signature prompt-cache) static-signature))
14571486
{:static (:static prompt-cache)
14581487
:dynamic (f.prompt/build-dynamic-instructions refined-contexts db)}
14591488
(let [result (f.prompt/build-chat-instructions
14601489
refined-contexts static-rules path-scoped-rules skills repo-map*
14611490
agent config chat-id all-tools db)]
14621491
(swap! db* assoc-in [:chats chat-id :prompt-cache]
14631492
{:static (:static result)
1493+
:static-signature static-signature
14641494
:agent agent
14651495
:model full-model})
14661496
result))
@@ -1470,15 +1500,21 @@
14701500
seq
14711501
(f.prompt/contexts-str repo-map* nil))]
14721502
[{:type :text :text contexts-str}])
1503+
decision (message->decision message db config)
14731504
;; Cursor (and other volatile editor-state) is delivered per-turn in the
14741505
;; user message - never the system prompt - and only re-sent when it
14751506
;; changed. This keeps the cached system/prefix stable across turns,
14761507
;; avoiding llama.cpp full prompt re-processing on every cursor move. #464
1508+
;; Only normal prompts send this synthesized user message to the model;
1509+
;; /prompt-show and MCP prompts use different messages.
14771510
editor-state-context (f.prompt/build-editor-state-context refined-contexts)
1478-
editor-state-contents (when (and editor-state-context
1479-
(not= editor-state-context
1480-
(get-in db [:chats chat-id :last-editor-state])))
1481-
(swap! db* assoc-in [:chats chat-id :last-editor-state] editor-state-context)
1511+
editor-state-changed? (and editor-state-context
1512+
(not= editor-state-context
1513+
(get-in db [:chats chat-id :last-editor-state])))
1514+
_ (when (and editor-state-changed?
1515+
(= :prompt-message (:type decision)))
1516+
(swap! db* assoc-in [:chats chat-id :last-editor-state] editor-state-context))
1517+
editor-state-contents (when editor-state-changed?
14821518
[{:type :text :text editor-state-context}])
14831519
user-messages [{:role "user" :content (vec (concat [{:type :text :text message}]
14841520
expanded-prompt-contexts
@@ -1492,8 +1528,7 @@
14921528
:full-model full-model
14931529
:provider provider
14941530
:model model
1495-
:messenger messenger})
1496-
decision (message->decision message db config)]
1531+
:messenger messenger})]
14971532
;; Show original prompt to user, but LLM receives the modified version
14981533
(lifecycle/send-content! chat-ctx :user {:type :text
14991534
:content-id (:user-content-id chat-ctx)

‎src/eca/features/commands.clj‎

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,45 @@
512512
(multi-str "Context Usage" "" header "" "Estimated usage" "" zipped))
513513
(multi-str "Context Usage" header "" (mapv #(fmt-cat "" %) all-rows)))))
514514

515+
(defn ^:private prompt-show-section [title body]
516+
(when-not (string/blank? body)
517+
(multi-str
518+
"────────────────────────────────────────"
519+
(str "# " title)
520+
"────────────────────────────────────────"
521+
(string/trim body))))
522+
523+
(defn ^:private prompt-show-user-messages-body [user-messages]
524+
(->> (mapcat :content user-messages)
525+
(map-indexed (fn [idx content]
526+
(when-let [text (shared/not-blank
527+
(cond
528+
(some? (:text content)) (:text content)
529+
(= :image (:type content)) (format "[image: media-type=%s, base64 omitted (%s chars)]"
530+
(:media-type content)
531+
(count (or (:base64 content) "")))
532+
:else (pr-str (dissoc content :base64))))]
533+
(if (and (zero? idx) text)
534+
(string/replace-first text #"^/prompt-show(?:\s+|$)" "")
535+
text))))
536+
(remove string/blank?)
537+
(string/join "\n\n")))
538+
539+
(defn ^:private prompt-show-text [instructions user-messages]
540+
(let [{:keys [static dynamic]} (if (map? instructions)
541+
instructions
542+
{:static instructions :dynamic nil})
543+
system-prompt (->> [static dynamic]
544+
(remove string/blank?)
545+
(string/join "\n\n"))
546+
sections (remove nil?
547+
[(prompt-show-section "Instructions (System prompt)" system-prompt)
548+
(prompt-show-section "Chat (User prompt)" (prompt-show-user-messages-body user-messages))])]
549+
(multi-str
550+
(string/join "\n\n" sections)
551+
""
552+
"_Tool schemas are sent separately and are not included in this text dump._")))
553+
515554
(defn handle-command! [command args {:keys [chat-id db* config messenger full-model agent all-tools instructions user-messages metrics] :as chat-ctx}]
516555
(let [db @db*
517556
custom-cmds (custom-commands config (:workspace-folders db))
@@ -782,21 +821,10 @@
782821
msg (rules-msg config roots agent full-model all-tools)]
783822
{:type :chat-messages
784823
:chats {chat-id {:messages [{:role "system" :content [{:type :text :text msg}]}]}}})
785-
"prompt-show" (let [full-prompt (str "Instructions:\n" (f.prompt/instructions->str instructions) "\n"
786-
"Prompt:\n" (reduce
787-
(fn [s {:keys [content]}]
788-
(str
789-
s
790-
(reduce
791-
#(str %1 (string/replace-first (:text %2) "/prompt-show " "") "\n")
792-
""
793-
content)))
794-
""
795-
user-messages))]
796-
{:type :chat-messages
797-
:chats {chat-id {:messages [{:role "system"
798-
:content [{:type :text
799-
:text full-prompt}]}]}}})
824+
"prompt-show" {:type :chat-messages
825+
:chats {chat-id {:messages [{:role "system"
826+
:content [{:type :text
827+
:text (prompt-show-text instructions user-messages)}]}]}}}
800828
"subagents" (let [msg (subagents-msg config)]
801829
{:type :chat-messages
802830
:chats {chat-id {:messages [{:role "system" :content [{:type :text :text msg}]}]}}})

0 commit comments

Comments
 (0)