Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 92 additions & 6 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* :func:`compress_context` — the actual compression call. Runs the
configured compressor, splits the SQLite session, rotates the
session_id, notifies plugin context engines / memory providers, and
returns the compressed message list and freshly-built system prompt.
returns the compressed message list and active system prompt.

* :func:`try_shrink_image_parts_in_messages` — image-too-large recovery
helper that re-encodes ``data:image/...;base64,...`` parts at a smaller
Expand Down Expand Up @@ -53,6 +53,71 @@
)


def _builtin_memory_prompt_snapshot(agent: Any) -> Optional[Tuple[str, str]]:
"""Return the built-in memory text that can affect a system prompt.

``MemoryStore`` freezes this text until ``load_from_disk()``. Rendering
the frozen blocks after that reload lets compression retain the exact
cached system prompt when it already embeds the current memory (see
:func:`_cached_prompt_reflects_builtin_memory`). An unreadable snapshot
returns ``None`` so callers take the conservative rebuild path.
"""
store = getattr(agent, "_memory_store", None)
if store is None:
return "", ""
try:
memory = (
store.format_for_system_prompt("memory") or ""
if getattr(agent, "_memory_enabled", False)
else ""
)
user = (
store.format_for_system_prompt("user") or ""
if getattr(agent, "_user_profile_enabled", False)
else ""
)
except Exception:
return None
return memory, user


def _cached_prompt_reflects_builtin_memory(agent: Any, cached_prompt: str) -> bool:
"""Whether the cached system prompt already embeds current built-in memory.

The retention fast path must NOT compare the memory snapshot before vs
after the disk reload: on fresh-agent surfaces (gateway, TUI) the cached
prompt is restored from the session DB and can predate mid-session memory
writes that the fresh ``MemoryStore`` already picked up at init — the
snapshot is then identical on both sides of the reload while the prompt
itself is stale, and retaining it would latch old memory for the life of
the session (and re-persist it via ``update_system_prompt``).

Instead, verify the CURRENT (post-reload) rendered blocks appear verbatim
in the cached prompt, and that no leftover block header remains for a
target whose entries have since been emptied or disabled.
"""
snapshot = _builtin_memory_prompt_snapshot(agent)
if snapshot is None:
return False
try:
from tools.memory_tool import MEMORY_BLOCK_HEADERS
except Exception:
return False
for target, block in zip(("memory", "user"), snapshot):
block = block.strip()
if block:
# build_system_prompt_parts embeds the stripped block verbatim;
# the rendered text includes the usage header, so any entry
# change (or char-count change) breaks containment → rebuild.
if block not in cached_prompt:
return False
elif MEMORY_BLOCK_HEADERS[target] in cached_prompt:
# The prompt still carries a block for a target that is now
# empty/disabled — stale; rebuild.
return False
return True


def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
"""Whether the live in-memory SessionDB class structurally predates locks.

Expand Down Expand Up @@ -604,7 +669,8 @@ def compress_context(
Args:
agent: The owning :class:`AIAgent`.
messages: Current message history (will be summarised).
system_message: Current system prompt; rebuilt after compression.
system_message: Current system prompt; used when compression needs a
rebuilt cached prompt.
approx_tokens: Pre-compression token estimate, logged for ops.
task_id: Tool task scope (used for clearing file-read dedup state).
focus_topic: Optional focus string for guided compression — the
Expand Down Expand Up @@ -671,8 +737,9 @@ def compress_context(

_pre_msg_count = len(messages)
# In-place compaction (config: compression.in_place, see #38763). When True,
# this compaction rewrites the message list + rebuilds the system prompt but
# keeps the SAME session_id — no end_session, no parent_session_id child, no
# this compaction rewrites the message list and refreshes the system prompt
# when necessary, but keeps the SAME session_id — no end_session, no
# parent_session_id child, no
# `name #N` renumber, no contextvar/env/logging re-sync, no memory/context-
# engine session-switch. The conversation keeps one durable id for life,
# eliminating the session-rotation bug cluster. Default False during rollout.
Expand Down Expand Up @@ -1021,9 +1088,28 @@ def _release_lock() -> None:
})
_ensure_compressed_has_user_turn(messages, compressed)

cached_system_prompt = agent._cached_system_prompt
agent._invalidate_system_prompt()
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt

# Built-in memory is the only system-prompt input that a normal
# compaction reloads. When the cached prompt already embeds the
# freshly-reloaded memory blocks verbatim, keep the exact cached
# prompt so local backends retain their KV-cache prefix. Containment
# (not before/after snapshot equality) is required: fresh-agent
# surfaces restore the cached prompt from the session DB, where it
# can predate mid-session memory writes the in-memory snapshot has
# already absorbed. External providers can change their own prompt
# block during on_pre_compress(), so they retain the rebuild path.
if (
cached_system_prompt is not None
and getattr(agent, "_memory_manager", None) is None
and _cached_prompt_reflects_builtin_memory(agent, cached_system_prompt)
):
new_system_prompt = cached_system_prompt
agent._cached_system_prompt = cached_system_prompt
else:
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt

if agent._session_db:
try:
Expand Down
134 changes: 132 additions & 2 deletions tests/run_agent/test_413_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ def _fake_compress(messages, current_tokens=None, focus_topic=None):

with (
patch.object(agent.context_compressor, "compress", side_effect=_fake_compress),
patch.object(agent, "_build_system_prompt", return_value="new system prompt"),
patch.object(agent, "_build_system_prompt", return_value="new system prompt") as build_prompt,
patch("run_agent.estimate_request_tokens_rough", return_value=42),
):
compressed, new_system_prompt = agent._compress_context(
Expand All @@ -591,11 +591,141 @@ def _fake_compress(messages, current_tokens=None, focus_topic=None):
{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"},
{"role": "user", "content": "hello"},
]
assert new_system_prompt == "new system prompt"
assert new_system_prompt == "You are helpful."
build_prompt.assert_not_called()
assert events[0][0] == "lifecycle"
assert "Compacting context" in events[0][1]
assert events[1] == ("compress", "started")

def test_compression_reuses_cached_prompt_when_memory_snapshot_is_unchanged(self, agent):
"""A memory reload without new injected text must keep the cache prefix."""
agent.compression_enabled = False
agent._memory_enabled = True
agent._user_profile_enabled = False
agent._memory_manager = None
agent._cached_system_prompt = (
"cached system prompt\n\n<memory>same facts</memory>"
)
memory_store = MagicMock()
memory_store.format_for_system_prompt.return_value = "<memory>same facts</memory>"
agent._memory_store = memory_store

with (
patch.object(
agent.context_compressor,
"compress",
return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
),
patch.object(agent, "_build_system_prompt") as build_prompt,
):
_, new_system_prompt = agent._compress_context(
[{"role": "user", "content": "hello"}],
"system prompt",
approx_tokens=1234,
)

assert new_system_prompt is agent._cached_system_prompt
assert new_system_prompt == "cached system prompt\n\n<memory>same facts</memory>"
build_prompt.assert_not_called()
memory_store.load_from_disk.assert_called_once()

def test_compression_rebuilds_prompt_when_memory_snapshot_changes(self, agent):
"""A changed memory block must be reflected in the next model request."""
agent.compression_enabled = False
agent._memory_enabled = True
agent._user_profile_enabled = False
agent._memory_manager = None
agent._cached_system_prompt = (
"cached system prompt\n\n<memory>old facts</memory>"
)
memory_store = MagicMock()
memory_store.format_for_system_prompt.return_value = "<memory>new facts</memory>"
agent._memory_store = memory_store

with (
patch.object(
agent.context_compressor,
"compress",
return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
),
patch.object(agent, "_build_system_prompt", return_value="rebuilt system prompt") as build_prompt,
):
_, new_system_prompt = agent._compress_context(
[{"role": "user", "content": "hello"}],
"system prompt",
approx_tokens=1234,
)

assert new_system_prompt == "rebuilt system prompt"
build_prompt.assert_called_once_with("system prompt")
memory_store.load_from_disk.assert_called_once()

def test_compression_rebuilds_when_restored_prompt_predates_memory_write(self, agent):
"""Gateway fresh-agent path: a session-DB-restored prompt built with OLD
memory must be rebuilt even though the in-memory snapshot is identical
before and after the disk reload (the fresh MemoryStore already
absorbed the mid-session write at init). Guards the containment check
against regressing to before/after snapshot equality."""
agent.compression_enabled = False
agent._memory_enabled = True
agent._user_profile_enabled = False
agent._memory_manager = None
# Restored from SessionDB in an earlier process — built with fact A only.
agent._cached_system_prompt = "system prompt\n\n<memory>fact A</memory>"
memory_store = MagicMock()
# Fresh store loaded fact A + fact B at agent init; stable across reload.
memory_store.format_for_system_prompt.return_value = "<memory>fact A\nfact B</memory>"
agent._memory_store = memory_store

with (
patch.object(
agent.context_compressor,
"compress",
return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
),
patch.object(agent, "_build_system_prompt", return_value="rebuilt with fact B") as build_prompt,
):
_, new_system_prompt = agent._compress_context(
[{"role": "user", "content": "hello"}],
"system prompt",
approx_tokens=1234,
)

assert new_system_prompt == "rebuilt with fact B"
build_prompt.assert_called_once_with("system prompt")

def test_compression_rebuilds_when_prompt_has_leftover_block_for_emptied_memory(self, agent):
"""A prompt still carrying a memory block after all entries were
removed must be rebuilt — empty current blocks are vacuously
'contained', so the leftover-header check has to catch this."""
agent.compression_enabled = False
agent._memory_enabled = True
agent._user_profile_enabled = False
agent._memory_manager = None
agent._cached_system_prompt = (
"system prompt\n\nMEMORY (your personal notes) [1% — 10/2,200 chars]\nold fact"
)
memory_store = MagicMock()
memory_store.format_for_system_prompt.return_value = None # emptied
agent._memory_store = memory_store

with (
patch.object(
agent.context_compressor,
"compress",
return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
),
patch.object(agent, "_build_system_prompt", return_value="rebuilt without memory") as build_prompt,
):
_, new_system_prompt = agent._compress_context(
[{"role": "user", "content": "hello"}],
"system prompt",
approx_tokens=1234,
)

assert new_system_prompt == "rebuilt without memory"
build_prompt.assert_called_once_with("system prompt")

def test_preflight_compresses_oversized_history(self, agent):
"""When loaded history exceeds the model's context threshold, compress before API call."""
agent.compression_enabled = True
Expand Down
14 changes: 12 additions & 2 deletions tools/memory_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ def get_memory_dir() -> Path:
"""Return the profile-scoped memories directory."""
return get_hermes_home() / "memories"

# Stable header prefixes for the system-prompt memory blocks rendered by
# MemoryStore._render_block. Exported so compression's prompt-retention check
# (agent/conversation_compression.py) can detect a leftover block for a
# target whose entries have since been emptied — keep in lockstep with
# _render_block below.
MEMORY_BLOCK_HEADERS = {
"memory": "MEMORY (your personal notes)",
"user": "USER PROFILE (who the user is)",
}

ENTRY_DELIMITER = "\n§\n"


Expand Down Expand Up @@ -672,9 +682,9 @@ def _render_block(self, target: str, entries: List[str]) -> str:
pct = min(100, int((current / limit) * 100)) if limit > 0 else 0

if target == "user":
header = f"USER PROFILE (who the user is) [{pct}% — {current:,}/{limit:,} chars]"
header = f"{MEMORY_BLOCK_HEADERS['user']} [{pct}% — {current:,}/{limit:,} chars]"
else:
header = f"MEMORY (your personal notes) [{pct}% — {current:,}/{limit:,} chars]"
header = f"{MEMORY_BLOCK_HEADERS['memory']} [{pct}% — {current:,}/{limit:,} chars]"

separator = "═" * 46
return f"{separator}\n{header}\n{separator}\n{content}"
Expand Down
Loading