diff --git a/navi/config.py b/navi/config.py index b486674..2b08a0a 100644 --- a/navi/config.py +++ b/navi/config.py @@ -120,6 +120,7 @@ # Context compression context_compression_enabled: bool = True context_compression_threshold: float = 0.90 # trigger at 90% of ollama_num_ctx + context_compression_target: float = 0.65 # hysteresis target — after compression the kept region should fit ~65% of the window (trigger 90% → target 65% leaves real headroom instead of skimming just below the trigger and re-firing a few messages later) context_keep_recent: int = 8 # conversational turns to keep verbatim context_summary_temperature: float = 0.3 context_summary_max_tokens: int = 4000 # max output tokens for the summary LLM call diff --git a/navi/core/compressor.py b/navi/core/compressor.py index 7f215c6..d2d990e 100644 --- a/navi/core/compressor.py +++ b/navi/core/compressor.py @@ -390,6 +390,29 @@ keep_recent_messages=keep_recent_messages, ) + # Target hysteresis (turn-based / preturn mode): shrink the verbatim + # keep-recent window until the kept region fits the target fraction of the + # context window. A fixed keep_recent leaves roughly the same context size + # after compression (just below the 90% trigger), so the next few messages + # re-trigger it — the "125 -> 104, then 104 again after a couple of + # messages" cycle. Dropping the kept size to the 65% target leaves real + # headroom and folds the extra turns into the summary (same single LLM + # call, no extra round-trip). Midturn mode (keep_recent_messages set) + # already splits the in-flight turn aggressively, so a post-check in + # compress_session truncates if it is still over target. + if keep_recent_messages is None: + target_tokens = int(settings.ollama_num_ctx * settings.context_compression_target) + kept_tokens = ContextCompressor.estimate_context_tokens(system_msgs + to_keep) + _KEEP_RECENT_FLOOR = 2 + while ( + kept_tokens > target_tokens and effective_keep_recent > _KEEP_RECENT_FLOOR + ): + effective_keep_recent -= 1 + to_summarize, to_keep = partition_messages( + context, effective_keep_recent, keep_recent_messages=keep_recent_messages + ) + kept_tokens = ContextCompressor.estimate_context_tokens(system_msgs + to_keep) + # Fallback: if turn-based partition has nothing to compress but we are in # mid-turn mode (keep_recent_messages set), try an aggressive intra-turn # split keeping only the 2 newest messages of the current turn. @@ -576,7 +599,26 @@ ): return self._token_budget_truncate(context) return None - return result + + # Target hysteresis safety net: if the compressed context is still over + # the target fraction (midturn mode kept a huge in-flight turn, or even + # the keep_recent floor could not fit the window), drop oldest kept + # turns by token budget so a compression firing always leaves real + # headroom below the trigger — otherwise the next few messages re-fire + # compression and the context yo-yos at the trigger line. The dropped + # turns were kept verbatim (not summarized) and are lost; this only + # triggers when the verbatim kept region alone exceeds the target. + new_context, summary_text = result + # Access settings via the module (a local `from navi.config import settings` + # above shadows the module-level binding inside this function). + import navi.config as _cfg + + target_tokens = int(_cfg.settings.ollama_num_ctx * _cfg.settings.context_compression_target) + if self.estimate_context_tokens(new_context) > target_tokens: + truncated = self._token_budget_truncate(new_context, budget_tokens=target_tokens) + if truncated is not None: + new_context = truncated[0] + return new_context, summary_text def would_compress( self, diff --git a/tests/unit/core/test_compressor.py b/tests/unit/core/test_compressor.py index 4237608..d52df46 100644 --- a/tests/unit/core/test_compressor.py +++ b/tests/unit/core/test_compressor.py @@ -624,6 +624,155 @@ assert backend._call_idx == 1 +class TestTargetHysteresis: + """Target hysteresis: after compression the kept region must fit the target + fraction (~65%) of the context window, not just skim below the 90% trigger — + otherwise the next few messages re-fire compression and the context yo-yos + at the trigger line ("125 -> 104, then 104 again after a couple of messages"). + """ + + @staticmethod + def _override_settings(ollama_num_ctx: int): + """Patch both navi.config.settings and navi.core.compressor.settings. + + compress_context reads the module-level ``settings`` binding of + compressor.py; compress_session's safety net reads ``navi.config.settings`` + via a local ``import navi.config as _cfg``. Both must be patched for the + override to reach every code path. + """ + import navi.config as _config + import navi.core.compressor as _cm + from navi.config import Settings + + new = Settings( + _env_file=None, + ollama_num_ctx=ollama_num_ctx, + output_reserve_tokens=2048, + navi_persona_file="", + context_compression_target=0.65, + ) + prev_config = _config.settings + prev_cm = _cm.settings + _config.settings = new + _cm.settings = new + return prev_config, prev_cm + + @staticmethod + def _restore(prev_config, prev_cm): + import navi.config as _config + import navi.core.compressor as _cm + + _config.settings = prev_config + _cm.settings = prev_cm + + @pytest.mark.asyncio + async def test_preturn_adaptive_shrink_fits_target(self): + """Many large turns, turn-based (preturn) mode: a fixed keep_recent would + leave the kept region above the 65% target, so compress_context shrinks + keep_recent until the verbatim kept region fits. The extra turns fold + into the summary (still one LLM call).""" + prev_config, prev_cm = self._override_settings(ollama_num_ctx=10_000) + try: + # target_tokens = 10_000 * 0.65 = 6_500 + backend = FakeLLMBackend(responses=["Summary text"]) + context = [Message(role="system", content="sys")] + # 20 turns, each assistant ~2500 chars (~833 tokens) -> a turn ~835 + # tokens. 12 turns ~10_020 > 6_500; 7 turns ~5_845 < 6_500. + for i in range(20): + context.append(Message(role="user", content=f"u{i}")) + context.append(Message(role="assistant", content="a" * 2500)) + + new_context, _ = await compress_context( + context=context, + llm=backend, + model="test", + temperature=0.3, + keep_recent=12, + ) + assert new_context is not None + # system + summary + 7 kept turns * 2 msgs = 16 (not 1+1+24=26) + assert len(new_context) == 16 + # The kept region fits the target. + assert ContextCompressor.estimate_context_tokens(new_context) <= 6_500 + # Newest turns kept verbatim, oldest summarized away. + contents = {m.content for m in new_context} + assert "u19" in contents and "u13" in contents # last 7 turns kept + assert "u0" not in contents and "u12" not in contents # summarized + # Single LLM call — shrink used partition only, no extra summarization. + assert backend._call_idx == 1 + finally: + self._restore(prev_config, prev_cm) + + @pytest.mark.asyncio + async def test_preturn_no_shrink_when_already_under_target(self): + """Small context: the kept region already fits the target, so + keep_recent is not shrunk — normal compression behavior is preserved.""" + prev_config, prev_cm = self._override_settings(ollama_num_ctx=100_000) + try: + backend = FakeLLMBackend(responses=["Summary text"]) + context = [ + Message(role="system", content="sys"), + Message(role="user", content="1"), + Message(role="assistant", content="a1"), + Message(role="user", content="2"), + Message(role="assistant", content="a2"), + Message(role="user", content="3"), + Message(role="assistant", content="a3"), + ] + new_context, _ = await compress_context( + context=context, + llm=backend, + model="test", + temperature=0.3, + keep_recent=2, + ) + # system + summary + 2 kept turns = 6 — unchanged from before hysteresis. + assert len(new_context) == 6 + assert ContextCompressor.estimate_context_tokens(new_context) <= 65_000 + finally: + self._restore(prev_config, prev_cm) + + @pytest.mark.asyncio + async def test_safety_net_truncates_when_floor_still_over_target(self): + """Even the keep_recent floor (2 turns) exceeds the target — the verbatim + kept region cannot fit. compress_context returns the floor result (over + target), and compress_session's safety net then drops oldest kept turns + by token budget so a compression firing always leaves real headroom.""" + prev_config, prev_cm = self._override_settings(ollama_num_ctx=10_000) + try: + # target_tokens = 6_500. 6 turns, each assistant ~12_000 chars + # (~4_000 tokens) -> 2 turns ~8_000 > 6_500, so the floor (2) is + # still over target. compress_session must truncate further. + big = "x" * 12_000 + backend = FakeLLMBackend(responses=["Summary text"]) + compressor = ContextCompressor() + context = [Message(role="system", content="sys")] + for i in range(6): + context.append(Message(role="user", content=f"u{i}")) + context.append(Message(role="assistant", content=big)) + + result = await compressor.compress_session( + context=context, + llm=backend, + model="test", + temperature=0.3, + keep_recent=4, + ) + assert result is not None + new_context, _ = result + # Safety net brought the context under the target. + assert compressor.estimate_context_tokens(new_context) <= 6_500 + # Only the newest turn fit the budget; older kept turns + the summary + # were dropped (they were verbatim, not summarized — the documented + # safety-net loss for the pathological "kept region alone exceeds + # the target" case). + assert len(new_context) == 3 # system + newest user + its assistant + assert new_context[1].content == "u5" + assert not any(m.is_summary for m in new_context) + finally: + self._restore(prev_config, prev_cm) + + class TestWouldCompress: """would_compress must predict compress_session's actual outcome (no LLM call), so the agent only announces compression when it will really happen."""