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 <noreply@anthropic.com>
This commit is contained in:
Michael Jordan 2026-07-29 22:14:40 -04:00 committed by kshitij
parent d19c182879
commit 9ca4ee72ca
5 changed files with 145 additions and 6 deletions

View file

@ -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 = (

View file

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

View file

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

View file

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

View file

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