Skip to content

Commit 69011cf

Browse files
ericdalloeca-agent
andcommitted
Preserve full chat history across compactions
Instead of replacing all messages with a summary on compaction, append a compact_marker tombstone followed by the summary message. Messages sent to the LLM are filtered to only include those after the last compact_marker, while the full history is preserved in the database for UI replay on chat resume. - Add messages-after-last-compact-marker utility (reverse scan) - Filter past-messages/new-messages at all LLM-bound chokepoints - Add compact_marker case in message-content->chat-content for UI - Stop prune-tool-results! at tombstone boundary - Filter hook all-messages to post-compaction segment Closes #394 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca <git@eca.dev>
1 parent b3e969f commit 69011cf

File tree

7 files changed

+107
-18
lines changed

7 files changed

+107
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Add `chatRetentionDays` config to control chat and cache cleanup retention period, default changed from 7 to 14 days. Set to 0 to disable cleanup. #393
6+
- Preserve full chat history across compactions using tombstone markers instead of replacing messages. #394
67

78
## 0.123.3
89

src/eca/db.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
:status (or :idle :running :stopping :login)
5858
:created-at :number
5959
:login-provider :string
60-
:messages [{:role (or "user" "assistant" "tool_call" "tool_call_output" "reason")
60+
:messages [{:role (or "user" "assistant" "tool_call" "tool_call_output" "reason" "compact_marker" "server_tool_use" "server_tool_result")
6161
:content (or :string [::any-map]) ;; string for simple text, map/vector for structured content
6262
:content-id :string}]
6363
:task {:next-id :number

src/eca/features/chat.clj

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@
7979
(let [msg (nth messages i)
8080
role (:role msg)]
8181
(cond
82+
(= "compact_marker" role)
83+
{:pruned-messages result
84+
:freed-tokens freed-tokens}
85+
8286
(= "tool_call_output" role)
8387
(let [text (tool-output-text msg)
8488
tokens (estimate-tokens text)]
@@ -192,7 +196,12 @@
192196
{:role :assistant
193197
:content {:type :reasonFinished
194198
:id (:id message-content)
195-
:total-time-ms (:total-time-ms message-content)}}]))
199+
:total-time-ms (:total-time-ms message-content)}}]
200+
"compact_marker" [{:role :system
201+
:content {:type :text
202+
:text (if (:auto? message-content)
203+
"── Chat auto-compacted ──"
204+
"── Chat compacted ──")}}]))
196205

197206
(defn ^:private send-chat-contents! [messages chat-ctx]
198207
(doseq [message messages]
@@ -444,7 +453,8 @@
444453
modify-allowed? (= source-type :prompt-message)
445454
run-hooks? (#{:prompt-message :eca-command :mcp-prompt} source-type)
446455
user-messages (if run-hooks?
447-
(let [past-messages (get-in @db* [:chats chat-id :messages] [])
456+
(let [past-messages (shared/messages-after-last-compact-marker
457+
(get-in @db* [:chats chat-id :messages] []))
448458
{:keys [final-prompt additional-contexts stop?]}
449459
(run-pre-request-hooks! (assoc chat-ctx :message original-text
450460
:all-messages (into past-messages user-messages)))]
@@ -538,7 +548,8 @@
538548
:model-capabilities model-capabilities
539549
:user-messages user-messages
540550
:instructions instructions
541-
:past-messages (get-in @db* [:chats chat-id :messages] [])
551+
:past-messages (shared/messages-after-last-compact-marker
552+
(get-in @db* [:chats chat-id :messages] []))
542553
:config config
543554
:tools all-tools
544555
:provider-auth provider-auth
@@ -643,7 +654,8 @@
643654
(do
644655
(consume-steer-message! chat-id db* chat-ctx add-to-history!)
645656
{:tools tc-all-tools
646-
:new-messages (get-in @db* [:chats chat-id :messages])})))))
657+
:new-messages (shared/messages-after-last-compact-marker
658+
(get-in @db* [:chats chat-id :messages]))})))))
647659
received-msgs* add-to-history! user-messages)
648660
:on-reason (fn [{:keys [status id text external-id delta-reasoning? redacted? data]}]
649661
(lifecycle/assert-chat-not-stopped! chat-ctx)

src/eca/features/chat/tool_calls.clj

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[eca.features.tools.mcp :as f.tools.mcp]
88
[eca.llm-util :as llm-util]
99
[eca.logger :as logger]
10-
[eca.shared :refer [assoc-some]]))
10+
[eca.shared :as shared :refer [assoc-some]]))
1111

1212
(set! *warn-on-reflection* true)
1313

@@ -814,15 +814,17 @@
814814
:text (or hook-stop-reason "Tool rejected by hook")})
815815
(lifecycle/finish-chat-prompt! :idle chat-ctx) nil)
816816
{:tools all-tools
817-
:new-messages (get-in @db* [:chats chat-id :messages])})
817+
:new-messages (shared/messages-after-last-compact-marker
818+
(get-in @db* [:chats chat-id :messages]))})
818819
(if (get-in @db* [:chats chat-id :subagent])
819820
;; Subagent: user can't provide rejection input directly, so continue
820821
;; the LLM loop with a rejection message letting the subagent adapt
821822
(do (add-to-history! {:role "user"
822823
:content [{:type :text
823824
:text "I rejected one or more tool calls. The tool call was not allowed. Try a different approach to complete the task."}]})
824825
{:tools all-tools
825-
:new-messages (get-in @db* [:chats chat-id :messages])})
826+
:new-messages (shared/messages-after-last-compact-marker
827+
(get-in @db* [:chats chat-id :messages]))})
826828
(do (lifecycle/send-content! chat-ctx :system {:type :text
827829
:text "Tell ECA what to do differently for the rejected tool(s)"})
828830
(add-to-history! {:role "user"
@@ -835,4 +837,5 @@
835837
(if-let [continue-fn (:continue-fn chat-ctx)]
836838
(continue-fn all-tools user-messages)
837839
{:tools all-tools
838-
:new-messages (get-in @db* [:chats chat-id :messages])}))))))))))
840+
:new-messages (shared/messages-after-last-compact-marker
841+
(get-in @db* [:chats chat-id :messages]))}))))))))))

src/eca/shared.clj

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -314,17 +314,37 @@
314314
(str (cache/workspace-cache-file (or (:initial-workspace-folders db)
315315
(:workspace-folders db)) "db.transit.json" uri->filename)))
316316

317+
(defn messages-after-last-compact-marker
318+
"Returns messages after the last compact_marker, or all messages if none exists.
319+
Scans from the end for efficiency since markers are typically near the tail."
320+
[messages]
321+
(let [messages (vec messages)
322+
n (count messages)
323+
last-idx (loop [i (dec n)]
324+
(cond
325+
(neg? i) -1
326+
(= "compact_marker" (:role (messages i))) i
327+
:else (recur (dec i))))]
328+
(if (neg? last-idx)
329+
messages
330+
(subvec messages (inc last-idx)))))
331+
317332
(defn compact-side-effect!
318-
"Performs side effects after chat compaction: replaces history with summary,
319-
zeros token usage, and notifies the user."
333+
"Performs side effects after chat compaction: appends a compact_marker tombstone
334+
and summary to history, zeros token usage, and notifies the user."
320335
[{:keys [chat-id full-model db* messenger]} auto-compact?]
321-
;; Replace chat history with summary
336+
;; Append compact marker tombstone + summary to preserve full history
322337
(swap! db* (fn [db]
323-
(assoc-in db [:chats chat-id :messages]
324-
[{:role "user"
325-
:content [{:type :text
326-
:text (str "The conversation was compacted/summarized, consider this summary:\n"
327-
(get-in db [:chats chat-id :last-summary]))}]}])))
338+
(let [summary (get-in db [:chats chat-id :last-summary])
339+
messages (get-in db [:chats chat-id :messages] [])]
340+
(assoc-in db [:chats chat-id :messages]
341+
(conj messages
342+
{:role "compact_marker"
343+
:content {:auto? (boolean auto-compact?)}}
344+
{:role "user"
345+
:content [{:type :text
346+
:text (str "The conversation was compacted/summarized, consider this summary:\n"
347+
summary)}]})))))
328348

329349
;; Zero chat usage
330350
(swap! db* update-in [:chats chat-id] dissoc :usage)

test/eca/features/chat_test.clj

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,26 @@
296296

297297
(testing "handles empty message history"
298298
(let [db* (atom {:chats {"c1" {:messages []}}})]
299-
(is (zero? (#'f.chat/prune-tool-results! db* "c1" {}))))))
299+
(is (zero? (#'f.chat/prune-tool-results! db* "c1" {})))))
300+
301+
(testing "stops at compact_marker boundary, does not prune pre-compaction messages"
302+
(let [large-text (apply str (repeat 100000 "x"))
303+
messages [(make-tool-call-msg "old")
304+
(make-tool-output-msg "old" large-text)
305+
{:role "compact_marker" :content {:auto? false}}
306+
{:role "user" :content [{:type :text :text "summary"}]}
307+
(make-tool-call-msg "new")
308+
(make-tool-output-msg "new" large-text)]
309+
db* (atom {:chats {"c1" {:messages messages}}})
310+
freed (#'f.chat/prune-tool-results! db* "c1" {:protect-budget 0})]
311+
(is (pos? freed))
312+
(let [pruned (get-in @db* [:chats "c1" :messages])]
313+
;; Pre-compaction tool output should be untouched
314+
(is (= large-text
315+
(get-in (nth pruned 1) [:content :output :contents 0 :text])))
316+
;; Post-compaction tool output should be cleared
317+
(is (= "[content cleared to reduce context size]"
318+
(get-in (nth pruned 5) [:content :output :contents 0 :text])))))))
300319

301320
(deftest contexts-in-prompt-test
302321
(testing "When prompt contains @file we add a user message"

test/eca/shared_test.clj

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,38 @@
203203
(testing "returns nil when base is blank"
204204
(is (nil? (shared/join-api-url " " "/chat/completions")))))
205205

206+
(deftest messages-after-last-compact-marker-test
207+
(testing "returns all messages when no compact_marker exists"
208+
(let [messages [{:role "user" :content [{:type :text :text "hello"}]}
209+
{:role "assistant" :content [{:type :text :text "hi"}]}]]
210+
(is (= messages (shared/messages-after-last-compact-marker messages)))))
211+
212+
(testing "returns messages after the last compact_marker"
213+
(let [messages [{:role "user" :content [{:type :text :text "old msg"}]}
214+
{:role "compact_marker" :content {:auto? false}}
215+
{:role "user" :content [{:type :text :text "summary"}]}
216+
{:role "assistant" :content [{:type :text :text "response"}]}]]
217+
(is (= [{:role "user" :content [{:type :text :text "summary"}]}
218+
{:role "assistant" :content [{:type :text :text "response"}]}]
219+
(shared/messages-after-last-compact-marker messages)))))
220+
221+
(testing "handles multiple compact_markers, returns after last one"
222+
(let [messages [{:role "user" :content [{:type :text :text "old"}]}
223+
{:role "compact_marker" :content {:auto? false}}
224+
{:role "user" :content [{:type :text :text "summary 1"}]}
225+
{:role "compact_marker" :content {:auto? true}}
226+
{:role "user" :content [{:type :text :text "summary 2"}]}
227+
{:role "assistant" :content [{:type :text :text "latest"}]}]]
228+
(is (= [{:role "user" :content [{:type :text :text "summary 2"}]}
229+
{:role "assistant" :content [{:type :text :text "latest"}]}]
230+
(shared/messages-after-last-compact-marker messages)))))
231+
232+
(testing "returns empty when compact_marker is last message"
233+
(let [messages [{:role "user" :content [{:type :text :text "old"}]}
234+
{:role "compact_marker" :content {:auto? false}}]]
235+
(is (= [] (shared/messages-after-last-compact-marker messages)))))
236+
237+
(testing "handles empty messages"
238+
(is (= [] (shared/messages-after-last-compact-marker [])))))
239+
206240

0 commit comments

Comments
 (0)