From 9ca4ee72cadc8bf0ef7f1c62757023b8b60694b9 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 29 Jul 2026 22:14:40 -0400 Subject: [PATCH] feat(agent): make the micro-compaction cadence configurable The on/off switch was the only knob. A pass fired after every completed turn, absorbed exactly one exchange, and there was no way to ask for less. Since a pass is also what breaks the prompt-cache prefix, "how often does it run" and "how often do I pay a cache break" are the same question, and it had no answer. Add `compression.micro_compact_every_n_turns` (default 1, clamped to >= 1). At 1 the behaviour is what it was; at 5 you get a fifth of the breaks and a fifth of the reclaim rate. The counter advances per invocation rather than per committed pass, so a turn that finds nothing to absorb still moves the cadence along and cannot wedge it, and a bogus 0 or negative degrades to "every turn" instead of silently disabling compaction. Also expose `micro_compact_defrag_threshold_tokens`, which has been a hardcoded attribute on the compressor with no path from config since it was added. This does not give micro-compaction the prune's reclaim-size gate -- a pass still commits whatever the single absorbed exchange saved. It makes the break frequency tunable, which reaches the same end by absorbing less rather than by waiting for a bigger win. The docs now say that plainly, including that a reclaim threshold is the obvious follow-up and does not exist yet. Tests cover the skip-until-due window, the cursor and prefix staying untouched on skipped turns, the clamp, and that the feature is off unless enabled. Co-Authored-By: Claude Opus 5 --- agent/agent_init.py | 25 ++++++++++- agent/context_compressor.py | 19 +++++++++ docs/micro-compaction.md | 32 +++++++++++--- hermes_cli/config_defaults.py | 11 +++++ tests/agent/test_micro_compaction.py | 64 ++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 6 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index b716522d0a9..c24c5c65ff0 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1971,6 +1971,23 @@ def init_agent( compression_micro_compact = is_truthy_value( _compression_cfg.get("micro_compact"), default=False ) + # How often a pass runs, in completed turns. Each pass rewrites + # already-sent history and costs one prompt-cache break, so this is the + # dial for how often that cost is paid: 1 = every turn (most aggressive + # reclaim), 5 = one break per five turns. Clamped to >= 1. + compression_micro_compact_every_n_turns = max( + 1, + _parse_prune_int(_compression_cfg.get("micro_compact_every_n_turns", 1), 1), + ) + # Rolling-summary defrag threshold, in tokens. Lived on the compressor as + # a hardcoded attribute with no path from config until now. + compression_micro_compact_defrag_tokens = max( + 1, + _parse_prune_int( + _compression_cfg.get("micro_compact_defrag_threshold_tokens", 2000), + 2000, + ), + ) codex_app_server_auto_compaction = str( _compression_cfg.get("codex_app_server_auto", "native") or "native" ).lower() @@ -2426,10 +2443,16 @@ def init_agent( pass agent.compression_enabled = compression_enabled agent.compression_in_place = compression_in_place - # Apply micro-compaction flag to the compressor (enabled by default) + # Apply micro-compaction settings to the compressor (feature is opt-in) _cc = getattr(agent, "context_compressor", None) if _cc is not None and hasattr(_cc, "_micro_compact_enabled"): _cc._micro_compact_enabled = compression_micro_compact + if _cc is not None and hasattr(_cc, "_micro_compact_every_n_turns"): + _cc._micro_compact_every_n_turns = compression_micro_compact_every_n_turns + if _cc is not None and hasattr(_cc, "_micro_compact_defrag_threshold_tokens"): + _cc._micro_compact_defrag_threshold_tokens = ( + compression_micro_compact_defrag_tokens + ) agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction agent.max_compression_attempts = compression_max_attempts agent.compression_idle_compact_after_seconds = ( diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 8b71a517d2a..f8ff0b861b8 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1302,6 +1302,7 @@ class ContextCompressor(ContextEngine): self._micro_compact_last_failure_cursor = -1 self._micro_compact_passes = 0 self._micro_compact_tokens_saved_total = 0 + self._micro_compact_turns_since_pass = 0 def _begin_compression_telemetry( self, @@ -2161,6 +2162,12 @@ class ContextCompressor(ContextEngine): self._micro_compact_defrag_threshold_tokens: int = 2000 self._micro_compact_passes: int = 0 self._micro_compact_tokens_saved_total: int = 0 + # Cadence: run a pass every Nth completed turn. Each pass rewrites + # already-sent history and so breaks the prompt-cache prefix, which + # makes this the dial that sets how often that break is paid. 1 = + # every turn (most aggressive reclaim, one break per turn). + self._micro_compact_every_n_turns: int = 1 + self._micro_compact_turns_since_pass: int = 0 # Defer context-length resolution to first access (#32221): # get_model_context_length() can issue a synchronous /models HTTP @@ -5329,6 +5336,18 @@ This compaction should PRIORITISE preserving all information related to the focu if not self._micro_compact_enabled: return messages + # Cadence gate. A pass rewrites already-sent history, so it costs one + # prompt-cache break; `every_n_turns` is how an operator trades reclaim + # frequency against that cost. Counted per invocation rather than per + # committed pass so a turn that finds nothing to absorb still advances + # the cadence and cannot wedge it. + every_n = max(1, int(self._micro_compact_every_n_turns or 1)) + if every_n > 1: + self._micro_compact_turns_since_pass += 1 + if self._micro_compact_turns_since_pass < every_n: + return messages + self._micro_compact_turns_since_pass = 0 + n_messages = len(messages) if n_messages < 4: return messages diff --git a/docs/micro-compaction.md b/docs/micro-compaction.md index dd62f6478c6..dcb414b14c2 100644 --- a/docs/micro-compaction.md +++ b/docs/micro-compaction.md @@ -168,11 +168,26 @@ Micro-compaction is **off by default**. Turn it on explicitly: ```yaml compression: - micro_compact: true # default: false + micro_compact: true # default: false + micro_compact_every_n_turns: 1 # cadence — how often a pass runs + micro_compact_defrag_threshold_tokens: 2000 ``` -With it unset or `false` Hermes behaves exactly as it always has: batch-only -compaction. Everything else about compression is unchanged. +With `micro_compact` unset or `false` Hermes behaves exactly as it always has: +batch-only compaction. Everything else about compression is unchanged. + +`micro_compact_every_n_turns` is the knob that matters most after the on/off +switch, because it sets how often you pay the cache break described below. At +`1` a pass runs after every completed turn: the most aggressive reclaim, and +one broken prefix per turn. At `5` you get a fifth of the breaks and a fifth of +the reclaim rate, which is the right direction if your sessions are long-lived +and your provider's cache discount is deep. Values below `1` are clamped to `1` +rather than silently disabling the feature. The counter advances per turn, not +per committed pass, so a turn with nothing to absorb still moves the cadence +along and cannot wedge it. + +`micro_compact_defrag_threshold_tokens` is when the rolling summary gets +re-summarized instead of growing forever — see [Defrag](#defrag). It ships opt-in rather than on because of the prompt-cache cost described in the next section — that cost is real, it is not universally worth paying, and @@ -193,8 +208,15 @@ This is the same cost the proactive prune deliberately avoids. That path gates itself behind `compression.proactive_prune_min_reclaim_tokens` (4096 by default) precisely so its rewrites stay, in the words of the config comment, "one big episodic break instead of a tiny break every tool iteration." -Micro-compaction has no such gate: one exchange per turn means one break per -turn, by design. + +Micro-compaction has no equivalent *reclaim-size* gate — a pass commits +whatever the one absorbed exchange happened to save, large or small. What it +has instead is a *frequency* dial, `micro_compact_every_n_turns`. Raising it +makes the breaks rarer and more episodic, which is the same end the prune's +gate serves by a different route, though it gets there by absorbing less rather +than by waiting for a bigger win. If you want the prune's exact semantics here, +a reclaim threshold on micro-compaction is the obvious follow-up and does not +exist yet. So the honest framing is a trade of one cost for another, not a saving: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 7d52bc2502d..3fa9350af88 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -590,6 +590,17 @@ DEFAULT_CONFIG = { # measured that the amortized stall is worth # more to you than the cached-prefix discount. # See docs/micro-compaction.md. + "micro_compact_every_n_turns": 1, # cadence: run a pass every Nth completed + # turn. Since each pass costs one prompt-cache + # break, this is the dial for how often that + # cost is paid — 1 reclaims most aggressively + # at one break per turn, 5 trades reclaim rate + # for a fifth of the breaks. Clamped to >= 1. + # Ignored unless `micro_compact` is true. + "micro_compact_defrag_threshold_tokens": 2000, # once the rolling summary + # exceeds this many tokens, the next pass + # re-summarizes the summary itself instead of + # letting it grow without bound. "hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count "hygiene_timeout_seconds": 30, # max seconds gateway waits for pre-agent hygiene compression # WITHOUT forward progress. The summary call streams, so diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index 57b3b773ad7..c175a9b6132 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -80,6 +80,70 @@ class TestMicroCompaction: assert cc._micro_compact(list(messages)) == messages + def test_is_off_unless_explicitly_enabled(self): + # A pass rewrites already-sent history, breaking the prompt-cache + # prefix, so nobody inherits it from an update: it stays off until + # `compression.micro_compact` opts in. + cc = ContextCompressor( + model="test-model", + threshold_percent=0.75, + protect_first_n=1, + protect_last_n=2, + quiet_mode=True, + config_context_length=40960, + provider="test", + ) + cc._micro_summarize_one = lambda _text: "ROLLING SUMMARY" + messages = _conversation() + + assert cc._micro_compact_enabled is False + assert cc._micro_compact(list(messages)) == messages + + def test_cadence_of_one_runs_every_turn(self): + cc = _compressor() + cc._micro_compact_every_n_turns = 1 + messages = _conversation(exchanges=8) + + first = cc._micro_compact(list(messages)) + second = cc._micro_compact(list(first)) + + assert cc._micro_compact_cursor > 0 + assert len(_summary_markers(first)) == 1 + # Each turn absorbed something, so the transcript kept shrinking. + assert len(second) < len(first) + + def test_cadence_skips_turns_until_a_pass_is_due(self): + cc = _compressor() + cc._micro_compact_every_n_turns = 3 + messages = _conversation(exchanges=8) + + first = cc._micro_compact(list(messages)) + second = cc._micro_compact(list(first)) + + # Cache prefix untouched on the turns in between. + assert _summary_markers(first) == [] + assert _summary_markers(second) == [] + assert cc._micro_compact_cursor == 0 + + third = cc._micro_compact(list(second)) + + assert len(_summary_markers(third)) == 1 + assert not any("answer 0" in str(m.get("content")) for m in third) + # Counter rearmed for the next window. + assert cc._micro_compact_turns_since_pass == 0 + + def test_cadence_is_clamped_to_at_least_one(self): + # A bogus 0 or negative must not disable compaction silently, nor + # divide-by-zero: it degrades to "every turn". + for bogus in (0, -5): + cc = _compressor() + cc._micro_compact_every_n_turns = bogus + messages = _conversation(exchanges=8) + + result = cc._micro_compact(list(messages)) + + assert len(_summary_markers(result)) == 1 + def test_cursor_advances_across_successive_turns(self): cc = _compressor() messages = _conversation(exchanges=8)