fix(compression): do not let prune rearm lock out over-threshold sessions

Message-only rearm could sit just above the body estimate while provider
prompt_tokens (system + tool schemas) already exceeded threshold_tokens,
so prune no-oped forever with no log. Bypass that rearm short-circuit on
the billed basis, warn once when over-threshold reclamation no-ops, and
name attempts_exhausted when should_compress_info says run but the loop
skips.

Fixes #101889
This commit is contained in:
Justin Wilson
2026-09-03 00:51:25 -05:00
committed by kshitij
parent e245e40f73
commit 9f12121206
5 changed files with 306 additions and 5 deletions
+77 -1
View File
@@ -3548,6 +3548,10 @@ class ContextCompressor(ContextEngine):
# A committed prune is a prompt-cache boundary. Do not permit the next
# one until the prompt has regrown the tokens just reclaimed.
self._proactive_prune_rearm_tokens: int = 0
# Dedup key for the over-threshold "reclamation no-oped" warning
# (#101889) so a tool loop riding above the threshold warns once per
# distinct reason + rearm snapshot instead of every iteration.
self._last_reclaim_block_warn: "tuple[str, int] | None" = None
self.min_tail_user_messages = min_tail_user_messages
self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80))
self.quiet_mode = quiet_mode
@@ -4442,6 +4446,57 @@ class ContextCompressor(ContextEngine):
return result, pruned
def _billed_basis_over_threshold(self, current_tokens: "int | None") -> bool:
"""Whether a provider-billed reading says the session is over threshold.
``current_tokens`` is the provider's ``prompt_tokens`` (or the
overhead-aware fallback estimate): it counts the system prompt and tool
schemas, which the message-only estimate behind
``_proactive_prune_rearm_tokens`` does not. Used to stop schema
overhead from parking the prune rearm gate above a real request that is
already over ``threshold_tokens`` (#101889).
"""
return (
current_tokens is not None
and self.threshold_tokens > 0
and current_tokens >= self.threshold_tokens
)
def _warn_reclamation_no_op(
self,
reason: str,
current_tokens: "int | None",
before: "int | None" = None,
) -> None:
"""Warn when an over-threshold session's reclamation path no-ops.
A session sitting above ``threshold_tokens`` with every reclamation
path declining is the failure mode from #101889: context keeps growing
until the provider's hard limit rejects the request, with nothing in
the log to explain it. Silent below the threshold (a declined prune
there is ordinary hysteresis, not a lockout). Deduped on
``reason`` + the rearm snapshot so a busy tool loop logs once per
distinct state, not once per iteration; the key is cleared whenever a
prune commits so a later lockout warns again.
"""
if not self._billed_basis_over_threshold(current_tokens):
return
key = (reason, int(self._proactive_prune_rearm_tokens))
if self._last_reclaim_block_warn == key:
return
self._last_reclaim_block_warn = key
logger.warning(
"Context is over the compression threshold (~%s of %s tokens) but "
"reclamation did not run: %s (message-token estimate %s, prune "
"rearm mark %s). The session may keep growing until the provider "
"rejects the request — /compact to compress history now.",
f"{int(current_tokens):,}",
f"{int(self.threshold_tokens):,}",
reason,
"n/a" if before is None else f"{int(before):,}",
f"{int(self._proactive_prune_rearm_tokens):,}",
)
def prune_tool_results_only(
self, messages: List[Dict[str, Any]], current_tokens: int | None = None,
) -> tuple[List[Dict[str, Any]], int]:
@@ -4483,6 +4538,13 @@ class ContextCompressor(ContextEngine):
object is returned unchanged — the standard no-op caller contract
(callers gate bookkeeping on ``result is not input``).
The rearm gate is measured on message bodies only, so it is bypassed
(never the reclaim gate) when a provider-billed ``current_tokens``
reading already puts the request over ``threshold_tokens``: schema
overhead must not park an over-threshold session below the rearm mark
forever with no reclamation and no log (#101889). Every no-op taken
while over threshold is logged once per distinct reason.
Returns ``(messages, 0)`` — the input object — when disabled, below
the trigger, or when the reclaim gate rejects the commit.
"""
@@ -4492,10 +4554,17 @@ class ContextCompressor(ContextEngine):
return messages, 0
# Nothing to reclaim until there are messages outside the protected tail.
if len(messages) <= self.protect_last_n + self._protect_head_size(messages) + 1:
self._warn_reclamation_no_op("prune:tail_only", current_tokens)
return messages, 0
before = sum(_estimate_msg_budget_tokens(m) for m in messages)
if before < self._proactive_prune_rearm_tokens:
return messages, 0
# Message-only estimate is short of the runway. Honour it as
# prompt-cache hysteresis only while the real (billed) request is
# still under threshold — above it, the lockout is the bug. The
# under-threshold skip stays silent on purpose: ordinary
# hysteresis, not a stuck session.
if not self._billed_basis_over_threshold(current_tokens):
return messages, 0
# Capability gate BEFORE the expensive multi-pass scan: a bound store that
# can't persist the prune atomically (duck-typed/plugin session store
# without archive_and_compact) makes every prune a permanent no-op, so
@@ -4507,6 +4576,7 @@ class ContextCompressor(ContextEngine):
and session_id
and not callable(getattr(session_db, "archive_and_compact", None))
):
self._warn_reclamation_no_op("prune:store_cannot_persist", current_tokens)
return messages, 0
pruned_msgs, pruned_count = self._prune_old_tool_results(
messages,
@@ -4517,6 +4587,7 @@ class ContextCompressor(ContextEngine):
if not pruned_count:
# Standard no-op contract: hand back the INPUT object so callers
# can gate bookkeeping on `result is not input`.
self._warn_reclamation_no_op("prune:nothing_eligible", current_tokens)
return messages, 0
# Measured-savings gate (prompt-cache hysteresis): only commit when
# the prune reclaims a meaningful batch of tokens. Estimated on the
@@ -4524,6 +4595,9 @@ class ContextCompressor(ContextEngine):
after = sum(_estimate_msg_budget_tokens(m) for m in pruned_msgs)
reclaimed = max(0, before - after)
if reclaimed < self.proactive_prune_min_reclaim_tokens:
self._warn_reclamation_no_op(
"prune:reclaim_below_minimum", current_tokens, before=before
)
return messages, 0
# ``after`` includes the tool batch appended since the provider's last
# usage reading, so both the low-water mark and future gate use the
@@ -4556,6 +4630,8 @@ class ContextCompressor(ContextEngine):
# the micro-compaction sync (#98450) — one stamp site for the class.
stamp_db_persisted_markers(pruned_msgs)
self._proactive_prune_rearm_tokens = next_rearm_tokens
# Reclamation just ran: let a future lockout warn again.
self._last_reclaim_block_warn = None
return pruned_msgs, pruned_count
# ------------------------------------------------------------------
+12 -2
View File
@@ -8275,9 +8275,19 @@ def run_conversation(
_info = getattr(_compressor, "should_compress_info", None)
if _info is not None:
try:
_block_reason = _info(_real_tokens)[1]
_should_now, _block_reason = _info(_real_tokens)
except Exception:
_block_reason = None
_should_now, _block_reason = False, None
if _should_now and not _block_reason:
# The engine says compression SHOULD run, yet this
# branch was taken — the per-turn attempt budget is
# spent. Over threshold with no reclamation left is
# exactly the silent-lockout case, so name it
# instead of dropping the (True, None) on the floor
# (#101889).
_block_reason = (
f"attempts_exhausted:{compression_attempts}"
)
if _block_reason:
agent._warn_context_overflow_blocked(
_block_reason,
@@ -0,0 +1,189 @@
"""Proactive-prune rearm must not lock out an over-threshold session (#101889).
``_proactive_prune_rearm_tokens`` is armed from a message-bodies-only estimate,
but the provider bills the system prompt and tool schemas too. On a schema-heavy
session the message-only estimate can sit permanently just below the rearm mark
while the real request rides *above* ``threshold_tokens`` — the prune declines
every iteration, full compression never gets there, and nothing is logged. The
session then grows until the provider rejects the request.
Pinned here as invariants (no frozen config literals): the gates are evaluated
against this compressor's own ``threshold_tokens`` / ``proactive_prune_tokens``.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
from unittest.mock import patch
from agent.context_compressor import ContextCompressor, _estimate_msg_budget_tokens
LARGE_WINDOW = 1_000_000
def _compressor(**kw: Any) -> ContextCompressor:
defaults = dict(
model="test",
quiet_mode=True,
threshold_percent=0.50,
protect_first_n=2,
protect_last_n=4,
proactive_prune_tokens=48_000,
proactive_prune_min_result_chars=8_000,
)
defaults.update(kw)
with patch(
"agent.context_compressor.get_model_context_length",
return_value=LARGE_WINDOW,
):
return ContextCompressor(**defaults)
def _history(n_pairs: int = 8, big: int = 9_000) -> List[Dict[str, Any]]:
msgs: List[Dict[str, Any]] = [{"role": "system", "content": "sys"}]
for i in range(n_pairs):
cid = f"call_{i}"
msgs.append({
"role": "assistant",
"content": "",
"tool_calls": [{
"id": cid,
"type": "function",
"function": {"name": "terminal", "arguments": '{"cmd":"ls"}'},
}],
})
msgs.append({
"role": "tool",
"tool_call_id": cid,
"content": chr(65 + i) * big if i < 3 else "ok",
})
return msgs
def _park_rearm_just_above_messages(
compressor: ContextCompressor, messages: List[Dict[str, Any]]
) -> int:
"""Reproduce the reporter's state: message-only estimate stuck 913 tokens
below the rearm mark (schema overhead makes up the rest of the request)."""
before = sum(_estimate_msg_budget_tokens(m) for m in messages)
compressor._proactive_prune_rearm_tokens = before + 913
assert before < compressor._proactive_prune_rearm_tokens
return before
def test_billed_basis_over_threshold_defeats_message_only_rearm_lockout() -> None:
"""Over ``threshold_tokens`` on the provider-billed basis, the rearm gate
must not short-circuit the prune on the message-only estimate alone."""
c = _compressor()
msgs = _history()
_park_rearm_just_above_messages(c, msgs)
billed = c.threshold_tokens + 1 # provider says: over threshold, now
scans: List[int] = []
# Stand in for the real multi-pass scan: a NEW list whose old tool outputs
# are reclaimed, so the (untouched) reclaim gate can commit it.
reclaimed = [dict(m) for m in msgs]
for m in reclaimed[:-2]:
if m.get("role") == "tool":
m["content"] = "[pruned]"
def _scan(*args: Any, **kwargs: Any) -> tuple[List[Dict[str, Any]], int]:
scans.append(1)
return reclaimed, 3
with patch.object(c, "_prune_old_tool_results", _scan):
result, pruned = c.prune_tool_results_only(msgs, current_tokens=billed)
assert scans, "rearm gate short-circuited on the message-only estimate"
assert pruned == 3
assert result is not msgs
def test_message_only_rearm_still_holds_below_threshold() -> None:
"""Prompt-cache hysteresis is intact while the real request is under the
compression threshold — the rearm bypass is an overflow escape hatch only."""
c = _compressor()
msgs = _history()
_park_rearm_just_above_messages(c, msgs)
under = c.threshold_tokens - 1
assert under >= c.proactive_prune_tokens # above the prune trigger
with patch.object(
c,
"_prune_old_tool_results",
side_effect=AssertionError("scan must not run below threshold"),
):
result, pruned = c.prune_tool_results_only(msgs, current_tokens=under)
assert result is msgs
assert pruned == 0
def test_no_op_below_the_prune_trigger() -> None:
"""Under ``proactive_prune_tokens`` nothing is reclaimed, rearm or not —
the bypass must not turn into over-pruning of small sessions."""
c = _compressor()
msgs = _history()
c._proactive_prune_rearm_tokens = 0 # fully rearmed; only the trigger gates
with patch.object(
c,
"_prune_old_tool_results",
side_effect=AssertionError("scan must not run below the trigger"),
):
result, pruned = c.prune_tool_results_only(
msgs, current_tokens=c.proactive_prune_tokens - 1
)
assert result is msgs
assert pruned == 0
def test_over_threshold_reclamation_no_op_warns_once(caplog) -> None:
"""A session riding above the threshold with every reclamation path
declining must be distinguishable in the log — and must not spam the same
reason on every tool iteration."""
# Reclaim floor above anything this transcript can free: the scan runs,
# finds candidates, and the commit gate rejects it — a silent no-op today.
c = _compressor(proactive_prune_min_reclaim_tokens=10_000_000)
msgs = _history()
billed = c.threshold_tokens + 5_000
with caplog.at_level(logging.WARNING, logger="agent.context_compressor"):
result, pruned = c.prune_tool_results_only(msgs, current_tokens=billed)
assert (result, pruned) == (msgs, 0)
warnings = [
r for r in caplog.records
if r.levelno >= logging.WARNING
and "over the compression threshold" in r.getMessage()
]
assert warnings, "over-threshold reclamation no-op was silent"
# Same state on the next tool iteration: deduped, not re-logged.
with caplog.at_level(logging.WARNING, logger="agent.context_compressor"):
c.prune_tool_results_only(msgs, current_tokens=billed)
assert len([
r for r in caplog.records
if r.levelno >= logging.WARNING
and "over the compression threshold" in r.getMessage()
]) == len(warnings)
def test_under_threshold_no_op_is_not_warned(caplog) -> None:
"""Ordinary hysteresis below the threshold stays quiet."""
c = _compressor(proactive_prune_min_reclaim_tokens=10_000_000)
msgs = _history()
with caplog.at_level(logging.WARNING, logger="agent.context_compressor"):
result, pruned = c.prune_tool_results_only(
msgs, current_tokens=c.threshold_tokens - 1
)
assert (result, pruned) == (msgs, 0)
assert not [
r for r in caplog.records
if r.levelno >= logging.WARNING
and "over the compression threshold" in r.getMessage()
]
@@ -130,7 +130,12 @@ def test_rearms_only_after_reclaimed_token_runway():
_tool_msg("call_9", "ok"),
]
assert sum(map(_estimate_msg_budget_tokens, grown)) < rearm_tokens
blocked, n2 = c.prune_tool_results_only(grown, current_tokens=1_000_000)
# Below the full-compression threshold, where the runway is pure
# prompt-cache hysteresis. (Above it the runway is bypassed on the
# provider-billed reading instead — see
# tests/agent/test_proactive_prune_rearm_threshold.py, #101889.)
_under_threshold = c.threshold_tokens - 1
blocked, n2 = c.prune_tool_results_only(grown, current_tokens=_under_threshold)
assert n2 == 0
assert blocked is grown
assert len(_tool_by_id(blocked, "call_6")["content"]) == 9000
@@ -139,7 +144,7 @@ def test_rearms_only_after_reclaimed_token_runway():
missing = rearm_tokens - sum(map(_estimate_msg_budget_tokens, grown))
regrown = grown + [{"role": "user", "content": "x" * (missing * 4)}]
assert sum(map(_estimate_msg_budget_tokens, regrown)) >= rearm_tokens
rearmed, n3 = c.prune_tool_results_only(regrown, current_tokens=1_000_000)
rearmed, n3 = c.prune_tool_results_only(regrown, current_tokens=_under_threshold)
assert n3 >= 2
assert rearmed is not regrown
@@ -189,6 +189,27 @@ class TestProactivePruneLoopWiring:
assert tool_rows, "expected tool rows in the final transcript"
assert all(m["content"] == marker for m in tool_rows)
def test_should_compress_true_but_skipped_is_warned(self, agent):
"""``should_compress_info`` says RUN (``(True, None)``) yet this branch
was taken — the per-turn compression budget is spent. Over threshold
with no reclamation running must not be swallowed silently (#101889)."""
agent.context_compressor.should_compress.return_value = False
agent.context_compressor.should_compress_info.return_value = (True, None)
agent.context_compressor.prune_tool_results_only = (
lambda messages, current_tokens=None: (messages, 0)
)
warned = []
with patch.object(
agent,
"_warn_context_overflow_blocked",
side_effect=lambda reason, tokens, threshold: warned.append(reason),
):
result = _run_tool_loop(agent, n_tool_iterations=1)
assert result["completed"] is True
assert warned, "over-threshold turn with no compaction ran silently"
assert all(r.startswith("attempts_exhausted") for r in warned)
def test_noop_input_object_commits_nothing(self, agent):
"""Engine returns the INPUT object with a (bogus) non-zero count —
the caller's ``result is not input`` gate must refuse the commit."""