From 47a6b510e1bbd7d3a191bd41587f4a2d761fc135 Mon Sep 17 00:00:00 2001 From: Ernest Hysa <59969602+ErnestHysa@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:10:58 +0100 Subject: [PATCH] Preserve system messages during context compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context compactor computed split_point against convo_msgs (system messages filtered out) but applied it directly to session.history which includes the system messages. After compaction, the original system prompt was dropped and replaced by an off-by-N slice of the full history. This silently dropped the system prompt (preset, persona, RAG context) from every compacted session — the model would lose persona, RAG, and preset guidance on the next turn after a long conversation. The split in maybe_compact does: convo_msgs = [m for m in messages if m['role'] != 'system'] split_point = len(convo_msgs) // 2 so split_point is indexed against the system-stripped list. But the helper _update_session_history took (session, split_point, summary) and did session.history[split_point:]. session.history is the full list including the leading system messages, so this dropped the first system_msg_count messages. Fix: pass system_msg_count=len(system_msgs) into _update_session_history and use session.history[system_msg_count + split_point:] as the recent slice, with session.history[:system_msg_count] prepended to preserve persona/preset/RAG system messages. Validated: tests/test_compactor_data_loss.py both tests now pass (were failing). tests/test_context_compactor.py 12 pre-existing tests still pass. Symptom was: post-compaction history = [summary] + assistant_1 + user_2 + assistant_2 (system_A was lost). Co-authored-by: Ernest Hysa --- src/context_compactor.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/context_compactor.py b/src/context_compactor.py index 890a9eb..2d0b15f 100644 --- a/src/context_compactor.py +++ b/src/context_compactor.py @@ -321,8 +321,12 @@ async def maybe_compact( compacted = system_msgs + [summary_msg] + recent - # Update session history to match - _update_session_history(session, split_point, summary) + # Update session history to match. Pass len(system_msgs) so the + # recent_history slice in _update_session_history uses the correct + # offset — session.history INCLUDES the system messages, but + # split_point is indexed against convo_msgs which does NOT. Without + # this, the slice drops the leading system message(s). + _update_session_history(session, split_point, summary, system_msg_count=len(system_msgs)) new_used = estimate_tokens(compacted) logger.info( @@ -333,22 +337,34 @@ async def maybe_compact( return compacted, context_length, True -def _update_session_history(session, split_point: int, summary: str): - """Update the in-memory session history after compaction.""" +def _update_session_history(session, split_point: int, summary: str, + system_msg_count: int = 0): + """Update the in-memory session history after compaction. + + `split_point` is the index in `convo_msgs` (system-stripped). The + in-memory `session.history` includes leading system messages, so the + actual recent-history slice starts at `system_msg_count + split_point`. + Prepending `session.history[:system_msg_count]` to the new history + preserves persona, preset, and RAG system messages that would + otherwise be dropped. + """ if not session or not hasattr(session, "history"): return - if split_point >= len(session.history): + effective_split = system_msg_count + split_point + if effective_split >= len(session.history): return - # Keep the recent messages, prepend summary - recent_history = session.history[split_point:] + # Keep the recent messages, prepend summary AND the leading system + # messages so the system prompt survives compaction. + system_prefix = list(session.history[:system_msg_count]) + recent_history = session.history[effective_split:] summary_msg = ChatMessage( role="system", content=f"[Conversation summary]\n{summary}", metadata={"compacted": True, "summarized_count": split_point}, ) - new_history = [summary_msg] + recent_history + new_history = system_prefix + [summary_msg] + recent_history try: from core import models as _core_models manager = getattr(_core_models, "_session_manager", None)