fix(context): persist fallback compaction breaker

This commit is contained in:
kshitijk4poor 2026-07-14 01:50:20 +05:30 committed by kshitij
parent 5ce827cac9
commit af7dceaf77
10 changed files with 394 additions and 91 deletions

View file

@ -282,7 +282,14 @@ def _record_codex_app_server_compaction(
# The app server has already completed a real compaction boundary. Its # The app server has already completed a real compaction boundary. Its
# usage update (when supplied) is therefore the same real-vs-real # usage update (when supplied) is therefore the same real-vs-real
# effectiveness verdict used by the normal compression path. # effectiveness verdict used by the normal compression path.
if hasattr(compressor, "_verify_compaction_cleared_threshold"): record_boundary = getattr(
type(compressor), "record_completed_compaction", None
)
if callable(record_boundary):
# Codex owns this summary. A prior Hermes deterministic-fallback
# flag must not leak into the native boundary's quality verdict.
record_boundary(compressor, used_fallback=False)
elif hasattr(compressor, "_verify_compaction_cleared_threshold"):
compressor._verify_compaction_cleared_threshold = True compressor._verify_compaction_cleared_threshold = True
if not getattr(turn, "token_usage_last", None): if not getattr(turn, "token_usage_last", None):
compressor.last_prompt_tokens = -1 compressor.last_prompt_tokens = -1

View file

@ -736,6 +736,7 @@ class ContextCompressor(ContextEngine):
self._last_aux_model_failure_model = None self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0 self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0 self._ineffective_compression_count = 0
self._fallback_compression_streak = 0
self._verify_compaction_cleared_threshold = False self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False self._last_compression_made_progress = False
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
@ -773,6 +774,7 @@ class ContextCompressor(ContextEngine):
self._last_aux_model_failure_model = None self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0 self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0 self._ineffective_compression_count = 0
self._fallback_compression_streak = 0
self._verify_compaction_cleared_threshold = False self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False self._last_compression_made_progress = False
self._summary_failure_cooldown_until = 0.0 self._summary_failure_cooldown_until = 0.0
@ -790,12 +792,84 @@ class ContextCompressor(ContextEngine):
self._session_id = session_id or "" self._session_id = session_id or ""
self._summary_failure_cooldown_until = 0.0 self._summary_failure_cooldown_until = 0.0
self._last_summary_error = None self._last_summary_error = None
self._fallback_compression_streak = 0
self.get_active_compression_failure_cooldown() self.get_active_compression_failure_cooldown()
self._load_fallback_compression_streak()
def on_session_start(self, session_id: str, **kwargs) -> None: def on_session_start(self, session_id: str, **kwargs) -> None:
"""Bind session-scoped compression state for a new or resumed session.""" """Bind session-scoped compression state for a new or resumed session."""
super().on_session_start(session_id, **kwargs) super().on_session_start(session_id, **kwargs)
self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id) boundary_reason = kwargs.get("boundary_reason")
old_session_id = kwargs.get("old_session_id")
session_db = kwargs.get("session_db", getattr(self, "_session_db", None))
previous_fallback_streak = self._fallback_compression_streak
if boundary_reason == "compression" and old_session_id:
getter = getattr(session_db, "get_compression_fallback_streak", None)
if callable(getter):
try:
stored_streak = getter(old_session_id)
if isinstance(stored_streak, (int, float, str)):
previous_fallback_streak = max(0, int(stored_streak))
except (TypeError, ValueError, sqlite3.Error) as exc:
logger.debug("compression parent fallback streak lookup failed: %s", exc)
except Exception as exc:
logger.debug(
"compression parent fallback streak lookup failed (non-sqlite): %s",
exc,
)
self.bind_session_state(session_db, session_id)
if boundary_reason == "compression":
# Rotation creates a fresh child row before this callback. Preserve
# the logical conversation's streak until boundary bookkeeping
# persists the updated value onto the child row.
self._fallback_compression_streak = previous_fallback_streak
def _load_fallback_compression_streak(self) -> None:
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
getter = getattr(session_db, "get_compression_fallback_streak", None)
if not session_id or not callable(getter):
return
try:
stored_streak = getter(session_id)
self._fallback_compression_streak = max(
0,
int(stored_streak)
if isinstance(stored_streak, (int, float, str))
else 0,
)
except (TypeError, ValueError, sqlite3.Error) as exc:
logger.debug("compression fallback streak lookup failed: %s", exc)
except Exception as exc:
logger.debug("compression fallback streak lookup failed (non-sqlite): %s", exc)
def _persist_fallback_compression_streak(self) -> None:
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
setter = getattr(session_db, "set_compression_fallback_streak", None)
if not session_id or not callable(setter):
return
try:
setter(session_id, self._fallback_compression_streak)
except sqlite3.Error as exc:
logger.debug("compression fallback streak persist failed: %s", exc)
except Exception as exc:
logger.debug("compression fallback streak persist failed (non-sqlite): %s", exc)
def record_completed_compaction(self, *, used_fallback: bool = False) -> None:
"""Record one completed boundary and its summary quality."""
self._verify_compaction_cleared_threshold = True
if used_fallback:
self._fallback_compression_streak += 1
if not self.quiet_mode:
logger.warning(
"Compaction completed with a deterministic fallback summary. "
"fallback_compression_streak=%d",
self._fallback_compression_streak,
)
elif self._fallback_compression_streak:
self._fallback_compression_streak = 0
self._persist_fallback_compression_streak()
def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]: def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]:
"""Return the live compression-failure cooldown for the bound session.""" """Return the live compression-failure cooldown for the bound session."""
@ -893,6 +967,12 @@ class ContextCompressor(ContextEngine):
max_tokens: int | None = None, max_tokens: int | None = None,
) -> None: ) -> None:
"""Update model info after a model switch or fallback activation.""" """Update model info after a model switch or fallback activation."""
runtime_changed = any((
model != self.model,
provider != self.provider,
base_url != self.base_url,
api_mode != self.api_mode,
))
self.model = model self.model = model
self.base_url = base_url self.base_url = base_url
self.api_key = api_key self.api_key = api_key
@ -948,6 +1028,9 @@ class ContextCompressor(ContextEngine):
self.last_compression_rough_tokens = 0 self.last_compression_rough_tokens = 0
self.awaiting_real_usage_after_compression = False self.awaiting_real_usage_after_compression = False
self._ineffective_compression_count = 0 self._ineffective_compression_count = 0
if runtime_changed:
self._fallback_compression_streak = 0
self._persist_fallback_compression_streak()
self._verify_compaction_cleared_threshold = False self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False self._last_compression_made_progress = False
@ -1137,6 +1220,10 @@ class ContextCompressor(ContextEngine):
# Anti-thrashing: track whether last compression was effective # Anti-thrashing: track whether last compression was effective
self._last_compression_savings_pct: float = 100.0 self._last_compression_savings_pct: float = 100.0
self._ineffective_compression_count: int = 0 self._ineffective_compression_count: int = 0
# Consecutive completed deterministic-fallback boundaries. Unlike the
# real-usage effectiveness counter, ordinary fitting responses must not
# reset this breaker; only a healthy completed summary does.
self._fallback_compression_streak: int = 0
# Set after a completed compression boundary; consumed by the next # Set after a completed compression boundary; consumed by the next
# provider-reported prompt count in update_from_response(). # provider-reported prompt count in update_from_response().
self._verify_compaction_cleared_threshold: bool = False self._verify_compaction_cleared_threshold: bool = False
@ -1186,23 +1273,15 @@ class ContextCompressor(ContextEngine):
self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens) self.last_total_tokens = usage.get("total_tokens", self.last_prompt_tokens + self.last_completion_tokens)
if self.last_prompt_tokens > 0: if self.last_prompt_tokens > 0:
self.last_real_prompt_tokens = self.last_prompt_tokens self.last_real_prompt_tokens = self.last_prompt_tokens
fallback_compaction = (
self._verify_compaction_cleared_threshold
and self._last_summary_fallback_used
)
if self.last_prompt_tokens < self.threshold_tokens: if self.last_prompt_tokens < self.threshold_tokens:
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0: if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
# Any real provider reading below the trigger proves the prompt # Any real provider reading below the trigger proves the prompt
# fits again, UNLESS the just-finished compaction had to fall # fits again. Clear the real-usage effectiveness latch even
# back to the deterministic placeholder summary. That path # when this response was not immediately after compaction. The
# does shrink the prompt, but it is still a degraded compaction # independent fallback streak is boundary-scoped and survives
# attempt and must count toward the anti-thrashing strike limit. # ordinary fitting responses during context regrowth.
# Otherwise repeated empty-summary failures can rotate through self._ineffective_compression_count = 0
# fallback markers forever while each smaller prompt reading
# resets the counter back to zero (#63008 / R1).
if not fallback_compaction:
self._ineffective_compression_count = 0
else: else:
self.last_rough_tokens_when_real_prompt_fit = 0 self.last_rough_tokens_when_real_prompt_fit = 0
@ -1223,17 +1302,7 @@ class ContextCompressor(ContextEngine):
# Keying on real usage compares like with like and fires exactly once # Keying on real usage compares like with like and fires exactly once
# per compaction. # per compaction.
if self._verify_compaction_cleared_threshold: if self._verify_compaction_cleared_threshold:
if fallback_compaction: if self.last_prompt_tokens >= self.threshold_tokens:
self._ineffective_compression_count += 1
if not self.quiet_mode:
logger.warning(
"Compaction completed with a deterministic fallback "
"summary. Counting this as a degraded attempt to "
"avoid repeated fallback-only compaction loops. "
"ineffective_compression_count=%d",
self._ineffective_compression_count,
)
elif self.last_prompt_tokens >= self.threshold_tokens:
self._ineffective_compression_count += 1 self._ineffective_compression_count += 1
if not self.quiet_mode: if not self.quiet_mode:
logger.warning( logger.warning(
@ -1305,6 +1374,10 @@ class ContextCompressor(ContextEngine):
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
if tokens < self.threshold_tokens: if tokens < self.threshold_tokens:
return False return False
return not self._automatic_compression_blocked()
def _automatic_compression_blocked(self) -> bool:
"""Return whether automatic compaction is in cooldown or tripped."""
# Do not trigger compression while the summary LLM is in cooldown. # Do not trigger compression while the summary LLM is in cooldown.
# On a 429/transient failure _generate_summary() sets a cooldown and # On a 429/transient failure _generate_summary() sets a cooldown and
# returns None; compress() then inserts a static fallback marker and # returns None; compress() then inserts a static fallback marker and
@ -1321,18 +1394,23 @@ class ContextCompressor(ContextEngine):
"Compression deferred — summary LLM in cooldown for %.0fs more", "Compression deferred — summary LLM in cooldown for %.0fs more",
_cooldown_remaining, _cooldown_remaining,
) )
return False return True
# Anti-thrashing: back off if recent compressions were ineffective # Anti-thrashing: back off if recent compressions were ineffective
if self._ineffective_compression_count >= 2: if (
self._ineffective_compression_count >= 2
or self._fallback_compression_streak >= 2
):
if not self.quiet_mode: if not self.quiet_mode:
logger.warning( logger.warning(
"Compression skipped — last %d compaction attempts did not " "Compression skipped — repeated compaction attempts did not "
"restore enough context headroom. Consider /new to start a " "restore healthy context. ineffective=%d fallback=%d. "
"fresh session, or /compress <topic> for focused compression.", "Consider /new to start fresh, or /compress <topic> for "
"focused compression.",
self._ineffective_compression_count, self._ineffective_compression_count,
self._fallback_compression_streak,
) )
return False return True
return True return False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Tool output pruning (cheap pre-pass, no LLM call) # Tool output pruning (cheap pre-pass, no LLM call)

View file

@ -504,6 +504,21 @@ def compress_context(
force=force, force=force,
) )
# Every automatic entrypoint must honor compressor-owned cooldown and
# breaker state. Gateway hygiene constructs a fresh AIAgent, so the
# persisted fallback streak is loaded by bind_session_state() before this.
if not force:
blocked = getattr(
type(agent.context_compressor),
"_automatic_compression_blocked",
None,
)
if callable(blocked) and blocked(agent.context_compressor):
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
# Lazy feasibility check — run the auxiliary-provider probe + context # Lazy feasibility check — run the auxiliary-provider probe + context
# length lookup just-in-time on the first compression attempt instead of # length lookup just-in-time on the first compression attempt instead of
# at AIAgent.__init__. Saves ~400ms cold off every short session that # at AIAgent.__init__. Saves ~400ms cold off every short session that
@ -718,6 +733,17 @@ def compress_context(
_release_lock() _release_lock()
raise raise
# Capture boundary quality before session-rotation callbacks run. Built-in
# and plugin lifecycle hooks may reset per-session compressor fields while
# rebinding to the child id; the completed attempt's verdict must survive
# that rebind and be recorded only after the full boundary commits.
_compression_made_progress = bool(
getattr(agent.context_compressor, "_last_compression_made_progress", False)
)
_compression_used_fallback = bool(
getattr(agent.context_compressor, "_last_summary_fallback_used", False)
)
# If compression aborted (aux LLM failed to produce a usable summary) # If compression aborted (aux LLM failed to produce a usable summary)
# the compressor returns the input messages unchanged. Surface the # the compressor returns the input messages unchanged. Surface the
# error to the user, skip the session-rotation work entirely (no # error to the user, skip the session-rotation work entirely (no
@ -1046,8 +1072,19 @@ def compress_context(
# the full compaction boundary. Exceptions, aborts, and no-op attempts # the full compaction boundary. Exceptions, aborts, and no-op attempts
# leave this false, so unrelated later usage cannot be charged to an # leave this false, so unrelated later usage cannot be charged to an
# attempt that never changed the transcript. # attempt that never changed the transcript.
if getattr(agent.context_compressor, "_last_compression_made_progress", False): if _compression_made_progress:
agent.context_compressor._verify_compaction_cleared_threshold = True record_boundary = getattr(
type(agent.context_compressor),
"record_completed_compaction",
None,
)
if callable(record_boundary):
record_boundary(
agent.context_compressor,
used_fallback=_compression_used_fallback,
)
else:
agent.context_compressor._verify_compaction_cleared_threshold = True
# Clear the file-read dedup cache. After compression the original # Clear the file-read dedup cache. After compression the original
# read content is summarised away — if the model re-reads the same # read content is summarised away — if the model re-reads the same

View file

@ -758,6 +758,7 @@ CREATE TABLE IF NOT EXISTS sessions (
handoff_error TEXT, handoff_error TEXT,
compression_failure_cooldown_until REAL, compression_failure_cooldown_until REAL,
compression_failure_error TEXT, compression_failure_error TEXT,
compression_fallback_streak INTEGER NOT NULL DEFAULT 0,
rewind_count INTEGER NOT NULL DEFAULT 0, rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id) FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
@ -2295,6 +2296,45 @@ class SessionDB:
"clear_compression_failure_cooldown(%s) failed: %s", "clear_compression_failure_cooldown(%s) failed: %s",
session_id, exc, session_id, exc,
) )
def get_compression_fallback_streak(self, session_id: str) -> int:
"""Return the persisted deterministic-fallback streak."""
if not session_id:
return 0
with self._lock:
conn = self._conn
if conn is None:
return 0
row = conn.execute(
"SELECT compression_fallback_streak FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
return 0
value = (
row["compression_fallback_streak"]
if isinstance(row, sqlite3.Row)
else row[0]
)
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def set_compression_fallback_streak(self, session_id: str, streak: int) -> None:
"""Persist the deterministic-fallback streak for one session."""
if not session_id:
return
normalized = max(0, int(streak))
def _do(conn):
conn.execute(
"UPDATE sessions SET compression_fallback_streak = ? WHERE id = ?",
(normalized, session_id),
)
self._execute_write(_do)
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
# Compression locks # Compression locks
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────

View file

@ -210,6 +210,70 @@ class TestFutilityGuard:
assert cc.awaiting_real_usage_after_compression is False assert cc.awaiting_real_usage_after_compression is False
assert cc._ineffective_compression_count == 0 assert cc._ineffective_compression_count == 0
def test_fallback_streak_survives_ordinary_fitting_responses(self):
cc = _compressor(threshold_tokens=24_576)
cc.record_completed_compaction(used_fallback=True)
cc.update_from_response({"prompt_tokens": 20_000})
assert cc._fallback_compression_streak == 1
# Context regrows through ordinary successful turns before the next
# fallback boundary. Those turns reset real-usage effectiveness, not
# the independent summary-quality breaker.
cc.update_from_response({"prompt_tokens": 20_000})
cc.record_completed_compaction(used_fallback=True)
cc.update_from_response({"prompt_tokens": 20_000})
assert cc._fallback_compression_streak == 2
assert not cc.should_compress(33_564)
def test_usage_less_fallback_boundary_still_counts(self):
cc = _compressor(threshold_tokens=24_576)
cc.record_completed_compaction(used_fallback=True)
cc.awaiting_real_usage_after_compression = True
cc.update_from_response({})
assert cc._fallback_compression_streak == 1
assert cc._verify_compaction_cleared_threshold is False
assert cc.awaiting_real_usage_after_compression is False
def test_healthy_boundary_resets_only_fallback_streak(self):
cc = _compressor(threshold_tokens=24_576)
cc.record_completed_compaction(used_fallback=True)
cc.record_completed_compaction(used_fallback=False)
assert cc._fallback_compression_streak == 0
assert cc._verify_compaction_cleared_threshold is True
def test_model_switch_resets_and_persists_fallback_streak(self, tmp_path):
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", source="cli")
cc = _compressor(threshold_tokens=24_576)
cc.bind_session_state(db, "s1")
cc.record_completed_compaction(used_fallback=True)
cc.update_model("next-model", 100_000)
assert cc._fallback_compression_streak == 0
assert db.get_compression_fallback_streak("s1") == 0
def test_same_runtime_context_recalibration_preserves_fallback_streak(self, tmp_path):
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", source="cli")
cc = _compressor(threshold_tokens=24_576)
cc.bind_session_state(db, "s1")
cc.record_completed_compaction(used_fallback=True)
cc.update_model(cc.model, 64_000, provider=cc.provider)
assert cc._fallback_compression_streak == 1
assert db.get_compression_fallback_streak("s1") == 1
def test_a_failed_pass_records_exactly_one_strike(self): def test_a_failed_pass_records_exactly_one_strike(self):
"""A compaction that leaves the real prompt over the threshold: one strike. """A compaction that leaves the real prompt over the threshold: one strike.

View file

@ -21,6 +21,7 @@ import os
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from agent.context_compressor import ContextCompressor
from hermes_state import SessionDB from hermes_state import SessionDB
@ -130,3 +131,96 @@ class TestPlatformForwardedAtBoundary:
kwargs = calls[-1].kwargs kwargs = calls[-1].kwargs
assert kwargs.get("platform") == "telegram" assert kwargs.get("platform") == "telegram"
assert kwargs.get("boundary_reason") == "compression" assert kwargs.get("boundary_reason") == "compression"
class TestFallbackStreakFollowsRotation:
def test_fallback_boundary_persists_on_child_session(self, tmp_path: Path):
db = SessionDB(db_path=tmp_path / "state.db")
parent = "PARENT_FALLBACK_ROT"
db.create_session(parent, source="telegram")
with patch(
"agent.context_compressor.get_model_context_length",
return_value=100_000,
):
compressor = ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=2,
protect_last_n=2,
quiet_mode=True,
)
compressor.bind_session_state(db, parent)
# A fallback streak must survive the session-id rotation itself. The
# boundary then records the just-completed fallback on the child row.
compressor.record_completed_compaction(used_fallback=True)
assert db.get_compression_fallback_streak(parent) == 1
db.create_session(
"CHILD_FALLBACK_ROT",
source="telegram",
parent_session_id=parent,
)
compressor.on_session_start(
"CHILD_FALLBACK_ROT",
session_db=db,
boundary_reason="compression",
old_session_id=parent,
)
assert compressor._fallback_compression_streak == 1
compressor.record_completed_compaction(used_fallback=True)
assert compressor._fallback_compression_streak == 2
assert db.get_compression_fallback_streak("CHILD_FALLBACK_ROT") == 2
resumed = ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=2,
protect_last_n=2,
quiet_mode=True,
)
resumed.bind_session_state(db, "CHILD_FALLBACK_ROT")
assert resumed._fallback_compression_streak == 2
def test_real_rotation_records_fallback_after_lifecycle_rebind(self, tmp_path: Path):
db = SessionDB(db_path=tmp_path / "state.db")
parent = "PARENT_REAL_FALLBACK_ROT"
db.create_session(parent, source="telegram")
agent = _build_agent_with_db(db, parent, platform="telegram")
with patch(
"agent.context_compressor.get_model_context_length",
return_value=100_000,
):
compressor = ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=2,
protect_last_n=2,
quiet_mode=True,
)
compressor.bind_session_state(db, parent)
compressed = [
{"role": "user", "content": "[CONTEXT COMPACTION] fallback"},
{"role": "assistant", "content": "tail"},
]
def _fallback_compress(*_args, **_kwargs):
compressor._last_summary_error = "empty summary"
compressor._last_summary_fallback_used = True
compressor._last_compression_made_progress = True
return compressed
with patch.object(
compressor,
"compress",
side_effect=_fallback_compress,
):
compressor.compression_count = 1
setattr(agent, "context_compressor", compressor)
agent._compress_context(_msgs(), "sys", approx_tokens=120_000)
child = getattr(agent, "session_id")
assert child != parent
assert compressor._fallback_compression_streak == 1
assert db.get_compression_fallback_streak(child) == 1

View file

@ -65,62 +65,6 @@ class TestUpdateFromResponse:
compressor.update_from_response({}) compressor.update_from_response({})
assert compressor.last_prompt_tokens == 0 assert compressor.last_prompt_tokens == 0
def test_fallback_compaction_counts_as_ineffective_even_when_prompt_fits(self, compressor):
compressor.threshold_tokens = 85_000
compressor.awaiting_real_usage_after_compression = True
compressor.last_compression_rough_tokens = 90_000
compressor._verify_compaction_cleared_threshold = True
compressor._last_summary_fallback_used = True
compressor.update_from_response({
"prompt_tokens": 5_000,
"completion_tokens": 100,
"total_tokens": 5_100,
})
assert compressor.last_real_prompt_tokens == 5_000
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
assert compressor._ineffective_compression_count == 1
assert compressor._verify_compaction_cleared_threshold is False
assert compressor.awaiting_real_usage_after_compression is False
def test_successful_compaction_fit_still_clears_prior_ineffective_count(self, compressor):
compressor.threshold_tokens = 85_000
compressor.awaiting_real_usage_after_compression = True
compressor.last_compression_rough_tokens = 90_000
compressor._verify_compaction_cleared_threshold = True
compressor._last_summary_fallback_used = False
compressor._ineffective_compression_count = 1
compressor.update_from_response({
"prompt_tokens": 5_000,
"completion_tokens": 100,
"total_tokens": 5_100,
})
assert compressor._ineffective_compression_count == 0
def test_second_fallback_strike_blocks_future_compression(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_prompt_tokens = 90_000
assert compressor.should_compress() is True
for expected in (1, 2):
compressor.awaiting_real_usage_after_compression = True
compressor.last_compression_rough_tokens = 90_000
compressor._verify_compaction_cleared_threshold = True
compressor._last_summary_fallback_used = True
compressor.update_from_response({
"prompt_tokens": 5_000,
"completion_tokens": 100,
"total_tokens": 5_100,
})
assert compressor._ineffective_compression_count == expected
compressor.last_prompt_tokens = 90_000
assert compressor.should_compress() is False
class TestPreflightDeferral: class TestPreflightDeferral:
def test_defers_when_recent_real_usage_fit_and_rough_growth_is_small(self, compressor): def test_defers_when_recent_real_usage_fit_and_rough_growth_is_small(self, compressor):
compressor.threshold_tokens = 85_000 compressor.threshold_tokens = 85_000

View file

@ -165,6 +165,7 @@ def test_reset_session_state_rebinds_builtin_compressor_after_session_switch(tmp
db.create_session("old-sid", source="cli") db.create_session("old-sid", source="cli")
db.create_session("new-sid", source="cli") db.create_session("new-sid", source="cli")
db.record_compression_failure_cooldown("old-sid", 4_000_000_000.0, "old-timeout") db.record_compression_failure_cooldown("old-sid", 4_000_000_000.0, "old-timeout")
db.set_compression_fallback_streak("old-sid", 2)
monkeypatch.setattr( monkeypatch.setattr(
"agent.context_compressor.get_model_context_length", "agent.context_compressor.get_model_context_length",
@ -188,7 +189,9 @@ def test_reset_session_state_rebinds_builtin_compressor_after_session_switch(tmp
assert compressor._session_id == "new-sid" assert compressor._session_id == "new-sid"
assert compressor.get_active_compression_failure_cooldown() is None assert compressor.get_active_compression_failure_cooldown() is None
assert compressor._fallback_compression_streak == 0
assert db.get_compression_failure_cooldown("old-sid") is not None assert db.get_compression_failure_cooldown("old-sid") is not None
assert db.get_compression_fallback_streak("old-sid") == 2
compressor._record_compression_failure_cooldown(30.0, "new-timeout") compressor._record_compression_failure_cooldown(30.0, "new-timeout")

View file

@ -194,3 +194,31 @@ def test_codex_app_server_native_compaction_notice_emits_status_and_event():
}, },
) )
] ]
def test_codex_native_boundary_clears_stale_hermes_fallback_streak():
from unittest.mock import patch
from agent.context_compressor import ContextCompressor
with patch(
"agent.context_compressor.get_model_context_length",
return_value=100_000,
):
compressor = ContextCompressor(model="test-model", quiet_mode=True)
compressor._fallback_compression_streak = 1
compressor._last_summary_fallback_used = True
agent = DummyAgent(
TurnResult(thread_id="thread-1", turn_id="normal-turn-1")
)
agent.context_compressor = compressor
turn = TurnResult(
thread_id="thread-1",
turn_id="normal-turn-1",
compacted=True,
)
assert _record_codex_app_server_compaction(agent, turn) is True
assert compressor._fallback_compression_streak == 0
assert compressor._verify_compaction_cleared_threshold is True

View file

@ -5791,6 +5791,14 @@ def test_expired_compression_failure_cooldown_is_ignored(db):
assert db.get_compression_failure_cooldown("s1") is None assert db.get_compression_failure_cooldown("s1") is None
def test_compression_fallback_streak_round_trips(db):
db.create_session("s1", "cli")
assert db.get_compression_fallback_streak("s1") == 0
db.set_compression_fallback_streak("s1", 2)
assert db.get_compression_fallback_streak("s1") == 2
def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(db, monkeypatch): def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(db, monkeypatch):
db.create_session("s1", "cli") db.create_session("s1", "cli")