mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
d19c182879
commit
9ca4ee72ca
5 changed files with 145 additions and 6 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue