From f8f475569f4f5ecfd447d2c554a3db17bd3ed8a5 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:09:26 +0530 Subject: [PATCH] perf(compressor): release allocator pages after successful compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful compaction frees the largest allocation a long session ever drops (the compressed-away message dicts), but Python's arena allocator keeps those pages in the heap — RSS retains the pre-compaction high-water mark until exit. #76905's trim_memory lifecycle covers the gateway/TUI housekeeping loops but not the CLI compression path. Call trim_memory(reason='post-compression') at the compression-success point in ContextCompressor.compress(), following the house pattern (lazy import in try, debug-level log on failure). The helper is glibc-gated, config-gated and rate-limited, so it is a safe no-op on other platforms and cannot fail compression. Re-expresses the intent of #70782 (JonthanaHanh), which reached for a bare gc.collect(); trim_memory is the house mechanism and already wraps a collect. --- agent/context_compressor.py | 20 +++++++ tests/agent/test_post_compression_trim.py | 66 +++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/agent/test_post_compression_trim.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index fbb7e6c5e82e0..59ae723d2bee1 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -6730,6 +6730,26 @@ This compaction should PRIORITISE preserving all information related to the focu _strip_persistence_markers(compressed) self._last_compression_made_progress = True + # A successful compaction just freed the largest allocation a long + # session ever drops (the compressed-away message dicts), which makes + # this the natural point to hand allocator pages back to the OS. + # #76905's trim lifecycle covers the gateway/TUI housekeeping loops but + # not the CLI compression path, so RSS keeps the pre-compaction + # high-water mark until exit. The helper is glibc-gated, config-gated + # and rate-limited, so this is a safe no-op elsewhere. (#70782) + try: + from hermes_cli.mem_trim import trim_memory + + trim_memory(reason="post-compression") + except Exception as exc: + # debug, not warning: sibling trim sites all log failures at + # debug, and compression must never fail because of a trim. + logger.debug( + "post-compression memory trim failed: %s: %s", + type(exc).__name__, + exc, + ) + # Batch compaction invalidates micro-compaction state: the batch # marker now holds MORE history than the in-memory rolling summary # (it summarized everything in the window, including exchanges micro diff --git a/tests/agent/test_post_compression_trim.py b/tests/agent/test_post_compression_trim.py new file mode 100644 index 0000000000000..4cb1aadc39b3e --- /dev/null +++ b/tests/agent/test_post_compression_trim.py @@ -0,0 +1,66 @@ +"""A successful compaction hands allocator pages back to the OS. + +The compressed-away message dicts are the largest allocation a long session +ever frees, but Python's arena allocator keeps those pages in the process heap +— RSS retains the pre-compaction high-water mark until exit. #76905's +trim_memory lifecycle covers the gateway/TUI housekeeping loops but not the +CLI compression path, so compress() now calls +``trim_memory(reason="post-compression")`` after a successful pass. + +trim_memory itself is glibc/Linux-gated (a fast no-op on macOS), so these +tests monkeypatch the seam rather than asserting on RSS. Salvaged in spirit +from #70782 (which reached for a bare gc.collect(); trim_memory is the +house mechanism and already wraps a collect). +""" +import hermes_cli.mem_trim as mem_trim +from agent.context_compressor import ContextCompressor + + +def _compressor(threshold_tokens: int = 24_576) -> ContextCompressor: + cc = ContextCompressor( + model="test-model", + threshold_percent=0.75, + protect_first_n=5, + protect_last_n=20, + quiet_mode=True, + config_context_length=40960, + provider="test", + ) + cc.threshold_tokens = threshold_tokens # pin; don't couple to window math + cc._generate_summary = lambda *a, **k: "Summary of earlier turns." + return cc + + +def _messages(n: int, size: int = 1500) -> list: + msgs = [{"role": "system", "content": "sys"}] + for i in range(n): + role = "user" if i % 2 == 0 else "assistant" + msgs.append({"role": role, "content": f"m{i} " + "z" * size}) + return msgs + + +def test_successful_compression_trims_memory_once(monkeypatch): + calls = [] + monkeypatch.setattr( + mem_trim, "trim_memory", lambda *a, **kw: calls.append(kw) or False + ) + + cc = _compressor() + out = cc.compress(_messages(14), current_tokens=100_000) + + assert len(out) < 15, "sanity: compaction should have made progress" + assert len(calls) == 1, "trim_memory must run exactly once per compaction" + assert calls[0].get("reason") == "post-compression" + + +def test_trim_failure_does_not_break_compression(monkeypatch): + def boom(*a, **kw): + raise RuntimeError("allocator says no") + + monkeypatch.setattr(mem_trim, "trim_memory", boom) + + cc = _compressor() + out = cc.compress(_messages(14), current_tokens=100_000) + + assert cc._last_compression_made_progress is True + assert isinstance(out, list) and out, "compress() must still return messages"