Preserve system messages during context compaction

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 <ernest@example.com>
This commit is contained in:
Ernest Hysa
2026-06-01 15:10:58 +01:00
committed by GitHub
parent 5e47e69e99
commit 47a6b510e1

View File

@@ -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)