Files
hermes-agent/agent/turn_context_compaction.py
kshitijk4poor 3ac22a309b refactor(agent): module-level _refund_api_call import; int verdict count (#77305)
turn_context_compaction imports neither conversation_loop nor
turn_empty_response, so the function-local import is not guarding a
cycle. The helper's docstring now also covers the provider-switch
fallback hop that reached the provider.
2026-09-24 22:27:18 +05:30

507 lines
23 KiB
Python

"""Turn-start compaction for ``build_turn_context`` plus the small compression-attempt
helpers shared with the pre-API / post-tool sites in ``turn_preflight``.
Three passes, in order: idle-triggered compaction (opt-in, wall-clock gap), preflight
context compression (token threshold), and the uncompressed-session overflow-warning
re-arm. ``run_turn_start_compaction`` mutates ``agent`` exactly as the inline prologue
did and returns a ``CompactionOutcome``. Predicates/estimators that tests patch on
``agent.turn_context`` are imported lazily through that module so patches intercept."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from agent.context_engine import automatic_compaction_status_message
from agent.conversation_compression import (
IDLE_COMPACTION_STATUS_TEMPLATE, PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
compression_skipped_due_to_lock, conversation_history_after_compression,
)
logger = logging.getLogger("agent.turn_context")
@dataclass
class CompactionOutcome:
"""Locals rebuilt by turn-start compaction (``build_turn_context`` reads them back)."""
messages: List[Dict[str, Any]]
active_system_prompt: Optional[str]
conversation_history: Optional[List[Dict[str, Any]]]
current_turn_user_idx: int
# A preflight pass (threshold or engine-driven) actually rebuilt ``messages``.
compressed: bool = False
# Preflight proved an immediate retry ineffective (no progress / insufficient).
blocked: bool = False
# ── Helpers shared by every compression-attempt site ──
def _clear_overflow_warn(agent: Any) -> None:
"""Re-arm the context-overflow warning dedup (test doubles may lack the method)."""
# Compression is actually running (block cleared / was never blocked) — reset the blocked-overflow
# warning dedup so a future blocked-over-threshold turn can warn again. Mirrors the turn-context
# preflight reset (silent-overflow fix #62625). getattr guard: test doubles built via object.__new__
# lack the method (gateway test-double pitfall) — treat absence as no-op.
# Compression is actually running (block cleared / was never blocked) — reset the blocked-overflow
# warning dedup so a future blocked-over-threshold turn can warn again (silent-overflow fix #62625).
# getattr guard: test doubles built via object.__new__ lack the method (gateway test-double pitfall) —
# treat absence as no-op.
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
if callable(_clear_warn):
_clear_warn()
def _reset_retry_state_after_compaction(agent: Any) -> None:
"""Give the compacted request a fresh chance: clear retry/empty-response state."""
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
def _blocked_compress_reason(
compressor: Any, tokens: int, attempts_spent: Optional[int] = None
) -> Optional[str]:
"""Why an over-threshold request is blocked (``None`` below threshold or when the
engine lacks ``should_compress_info`` / raises).
``attempts_spent``: when given and the engine says compression SHOULD run
(``(True, None)``) yet the caller skipped it, the per-turn attempt budget is
spent — name it ``attempts_exhausted:<n>`` instead of dropping the
``(True, None)`` on the floor (silent-lockout case, #101889)."""
_info = getattr(compressor, "should_compress_info", None)
if not callable(_info):
return None
try:
_should_now, _reason = _info(tokens)
except Exception:
return None
if attempts_spent is not None and _should_now and not _reason:
return f"attempts_exhausted:{attempts_spent}"
return _reason
def _apply_grown_window(agent: Any, compressor: Any, grown: int) -> None:
"""A managed local runtime granted a bigger window: recalibrate the compressor."""
compressor.update_model(
agent.model, grown, base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
provider=getattr(agent, "provider", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
)
agent._buffer_status(
f"📈 Context window grown to {grown // 1024}K "
f"(local model; conversation continues uncompressed)"
)
def _refund_api_call(agent: Any, api_call_count: int) -> int:
"""Refund the call count and iteration budget for a pass that should not consume it:
one that never reached the provider (preflight) or a provider-switch fallback hop."""
# Host progress-aware timeout (#98722, salvaged from #98741): this preflight iteration never reached the
# provider. Refund its provisional call/budget exactly like a successful pre-API compaction, then stop
# before the unchanged oversized request reaches the provider — its overflow error would only invoke
# compression again on the same transcript with the wait budget already spent.
api_call_count -= 1
agent._api_call_count = api_call_count
agent.iteration_budget.refund()
return api_call_count
def _reanchor(agent: Any, messages: List[Any], user_message: Any) -> int:
"""Compaction rebuilt ``messages``: re-anchor this turn's user index so the
api_content stamp, injection site and persist-override row hit the same dict."""
from agent.turn_context import reanchor_current_turn_user_idx
idx = reanchor_current_turn_user_idx(messages, user_message)
agent._persist_user_message_idx = idx
return idx
# ── Turn-start passes ──
def run_turn_start_compaction(
agent: Any, *, messages: List[Dict[str, Any]], system_message: Optional[str],
active_system_prompt: Optional[str], conversation_history: Optional[List[Dict[str, Any]]],
current_turn_user_idx: int, user_message: Any, effective_task_id: str,
) -> CompactionOutcome:
"""Idle compaction, then preflight compression (or the uncompressed guard)."""
out = CompactionOutcome(
messages=messages, active_system_prompt=active_system_prompt,
conversation_history=conversation_history, current_turn_user_idx=current_turn_user_idx,
)
_idle_compaction(agent, out, system_message, user_message, effective_task_id)
_preflight_compression(agent, out, system_message, user_message, effective_task_id)
return out
def _idle_compaction(
agent: Any, out: CompactionOutcome, system_message: Optional[str], user_message: Any,
effective_task_id: str,
) -> None:
"""Idle-triggered compaction (opt-in; ``idle_compact_after_seconds``): fires on the
wall-clock gap since ``_last_activity_ts``; a cheap gap check gates the estimate."""
from agent import turn_context as _tc
messages = out.messages
_idle_after = getattr(agent, "compression_idle_compact_after_seconds", 0)
if not (agent.compression_enabled and _idle_after > 0 and messages):
return
_idle_gap = time.time() - getattr(agent, "_last_activity_ts", time.time())
if _idle_gap < _idle_after:
return
_compressor = agent.context_compressor
# A live or restored native checkpoint must reach its issuer once so real usage,
# rather than an opaque ciphertext estimate, decides whether local compression is
# still needed. Threshold and post-tool preflight honor the same latch.
if bool(getattr(_compressor, "awaiting_real_usage_after_compression", False)):
return
# Route-aware pressure: on compacted native-Codex sessions the durable figure
# overstates the wire, so reuse the preflight estimator.
_idle_tokens = _tc._preflight_request_tokens(
agent, messages, out.active_system_prompt or ""
)
# Don't summarise a thread already below the post-compression target size.
_idle_floor = int(_compressor.threshold_tokens * _compressor.summary_target_ratio)
_idle_cooldown = getattr(
_compressor, "get_active_compression_failure_cooldown", lambda: None
)()
# What the previous pass actually produced — the honest floor versus the theoretical
# ``_idle_floor``. Type pin: compressor doubles expose truthy non-ints here; only a real
# int may raise the floor, anything else falls back to 0 (original semantics).
_idle_last_compaction = getattr(_compressor, "last_compression_rough_tokens", 0)
if not isinstance(_idle_last_compaction, int) or isinstance(_idle_last_compaction, bool):
_idle_last_compaction = 0
if not _tc._should_idle_compact(
enabled=agent.compression_enabled, idle_after_seconds=_idle_after,
idle_gap_seconds=_idle_gap, tokens=_idle_tokens, floor_tokens=_idle_floor,
cooldown_active=bool(_idle_cooldown), last_compaction_tokens=_idle_last_compaction,
):
return
logger.info(
"Idle compaction: %ss idle >= %ss, ~%s tokens > %s floor (last compaction produced ~%s) (session %s)",
int(_idle_gap), _idle_after, f"{_idle_tokens:,}", f"{_idle_floor:,}",
f"{_idle_last_compaction:,}" if _idle_last_compaction > 0 else "n/a",
agent.session_id or "none",
)
_idle_status = automatic_compaction_status_message(
_compressor,
phase="idle",
default_message=IDLE_COMPACTION_STATUS_TEMPLATE.format(
idle_seconds=int(_idle_gap), tokens=_idle_tokens
),
approx_tokens=_idle_tokens,
idle_seconds=int(_idle_gap),
model=agent.model,
)
if _idle_status:
agent._emit_status(_idle_status)
out.messages, out.active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_idle_tokens, task_id=effective_task_id
)
# ``_compress_context`` returns the INPUT list object when it skips; only
# re-baseline and re-anchor after a real compaction.
if out.messages is not messages:
out.conversation_history = conversation_history_after_compression(
agent, out.messages, out.conversation_history
)
out.current_turn_user_idx = _reanchor(agent, out.messages, user_message)
def _codex_native_auto_compaction(agent: Any) -> bool:
"""Codex app-server threads are compacted by the codex agent itself; Hermes only
initiates compaction in "hermes" mode."""
return (
# See #36801.
getattr(agent, "api_mode", None) == "codex_app_server"
and str(
getattr(agent, "codex_app_server_auto_compaction", "native") or "native"
).lower()
in {"native", "off"}
)
def _preflight_compression(
agent: Any, out: CompactionOutcome, system_message: Optional[str], user_message: Any,
effective_task_id: str,
) -> None:
"""Preflight context compression; the cheap pre-check gates the full estimate
(see ``_should_run_preflight_estimate`` for the OR semantics)."""
from agent import turn_context as _tc
agent._turn_received_provider_response = False
agent._turn_preflight_display_snapshot = None
if not agent.compression_enabled:
_rearm_uncompressed_overflow_warn(agent, out.messages, out.active_system_prompt)
return
_compressor = agent.context_compressor
if _tc._review_fork_first_request_pending(agent) or not _tc._should_run_preflight_estimate(
out.messages, _compressor.protect_first_n, _compressor.protect_last_n,
_compressor.threshold_tokens,
):
return
_preflight_tokens = _tc._preflight_request_tokens(
agent, out.messages, out.active_system_prompt or ""
)
# getattr guard: compressor doubles and plugin engines lack this method — absence
# means no snapshot and the finalizer's rollback stays disarmed.
_snapshot_fn = getattr(_compressor, "snapshot_preflight_display_tokens", None)
if callable(_snapshot_fn):
_snapshot_val = _snapshot_fn()
# Type pin: MagicMock compressors return truthy Mock objects — only a real int
# snapshot may arm the interrupted-turn rollback.
if isinstance(_snapshot_val, int) and not isinstance(_snapshot_val, bool):
agent._turn_preflight_display_snapshot = _snapshot_val
# An anchored figure is real usage + delta: never deferred.
_preflight_deferred = not getattr(agent, "_request_pressure_anchored", False) and getattr(
_compressor, "should_defer_preflight_to_real_usage", lambda _tokens: False
)(_preflight_tokens)
_codex_native_auto = _codex_native_auto_compaction(agent)
if not _preflight_deferred:
# Display-only seed: a real provider reading wins and the -1 sentinel stays
# protected. Also feeds the tool-loop gate on usage-less responses.
_maybe_seed = getattr(_compressor, "maybe_seed_preflight_display_tokens", None)
if callable(_maybe_seed):
_maybe_seed(_preflight_tokens)
_compression_cooldown = getattr(
_compressor, "get_active_compression_failure_cooldown", lambda: None
)()
_should_compress_now = False
_compress_block_reason = None
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s is not anchored on "
"real usage (last real provider prompt %s); deferring to the next response",
f"{_preflight_tokens:,}", f"{_compressor.threshold_tokens:,}",
f"{_compressor.last_real_prompt_tokens:,}",
)
elif _compression_cooldown:
logger.info(
"Skipping preflight compression: same-session cooldown active "
"(~%s seconds remaining, session %s)",
int(_compression_cooldown.get("remaining_seconds", 0.0)),
agent.session_id or "none",
)
if _preflight_tokens >= _compressor.threshold_tokens:
# Over threshold but blocked by the summary-LLM cooldown — surface a warning.
_cooldown_secs = _compression_cooldown.get("remaining_seconds", 0.0)
_compress_block_reason = f"cooldown:{_cooldown_secs:.0f}"
elif _codex_native_auto:
logger.info(
"Skipping Hermes preflight compression for codex app-server "
"(mode=%s); Hermes will not start thread compaction here.",
getattr(agent, "codex_app_server_auto_compaction", "native"),
)
else:
_should_compress_now = _compressor.should_compress(_preflight_tokens)
if not _should_compress_now:
_compress_block_reason = _blocked_compress_reason(_compressor, _preflight_tokens)
if _should_compress_now:
# Managed local runtime: growing the window beats compressing (ladder order;
# same seam as _maybe_grow_local_window in the loop).
try:
from agent.conversation_loop import _maybe_grow_local_window
_grown = _maybe_grow_local_window(agent, _compressor, _preflight_tokens)
except Exception:
_grown = None
if _grown:
_apply_grown_window(agent, _compressor, _grown)
_should_compress_now = _compressor.should_compress(_preflight_tokens)
if _should_compress_now:
_run_preflight_passes(
agent, out, _compressor, _preflight_tokens, system_message, effective_task_id
)
elif _compress_block_reason:
# Over threshold but compression blocked: surface a deduped warning so the
# user can /new or /compress instead of a silent provider limit.
agent._warn_context_overflow_blocked(
_compress_block_reason, _preflight_tokens, _compressor.threshold_tokens
)
else:
# Sub-threshold and unblocked — re-arm the overflow warning.
_clear_overflow_warn(agent)
# Engine maintenance only when NO skip-branch fired: cooldown, deferred
# estimate, or codex-native route keep the engine hook unconsulted.
if not (_compression_cooldown or _preflight_deferred or _codex_native_auto):
_engine_preflight_maintenance(
agent, out, _compressor, _preflight_tokens, system_message, effective_task_id
)
if out.compressed:
# Compression rebuilt the list, so the pre-compression user index is stale.
# Exact-content match first so a todo-snapshot can't steal it.
out.current_turn_user_idx = _reanchor(agent, out.messages, user_message)
def _run_preflight_passes(
agent: Any, out: CompactionOutcome, _compressor: Any, _preflight_tokens: int,
system_message: Optional[str], effective_task_id: str,
) -> None:
"""Threshold-triggered preflight passes (honor ``compression.max_attempts`` like
the loop's sites, default 3)."""
from agent import turn_context as _tc
out.compressed = True
# Compression is actually running — reset the dedup so a future blocked turn can
# warn again.
_clear_overflow_warn(agent)
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}", f"{_compressor.threshold_tokens:,}", agent.model,
f"{_compressor.context_length:,}",
)
_preflight_status = automatic_compaction_status_message(
_compressor,
phase="preflight",
default_message=PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
tokens=_preflight_tokens, threshold=_compressor.threshold_tokens
),
approx_tokens=_preflight_tokens,
threshold_tokens=_compressor.threshold_tokens,
context_length=_compressor.context_length,
model=agent.model,
)
if _preflight_status:
agent._emit_status(_preflight_status)
_max_preflight_passes = max(1, int(getattr(agent, "max_compression_attempts", 3) or 3))
for _pass in range(_max_preflight_passes):
_preflight_input = out.messages
_orig_len = len(_preflight_input)
_orig_tokens = _preflight_tokens
out.messages, out.active_system_prompt = agent._compress_context(
_preflight_input, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if out.messages is _preflight_input and compression_skipped_due_to_lock(agent):
# Lock-skip: another path holds the lock, so this is a DEFER, not proof of
# incompressibility — don't arm the blocker; stop passes this turn.
logger.info(
# That is a temporary DEFER, not proof the transcript cannot compress — do NOT arm the
# insufficient-progress blocker (the loop's error handlers must keep their provider-proven
# retry budget) and stop preflight passes for this turn; the lock winner is shrinking the
# same session concurrently. See #69870.
"Preflight compression deferred: compression lock "
"held by another path (session %s)",
agent.session_id or "none",
)
break
# Re-estimate so size-only compression (same rows, fewer tokens) counts as
# progress.
_preflight_tokens = _tc._preflight_request_tokens(
agent, out.messages, out.active_system_prompt or ""
)
if not _tc.compression_made_progress(
_orig_len, len(out.messages), _orig_tokens, _preflight_tokens
):
_tc._fail_closed_after_preflight_timeout(agent, _preflight_tokens)
_tc._fail_closed_on_insufficient_progress(agent, _preflight_tokens)
out.blocked = True
break # Cannot compress further: neither rows nor tokens moved
out.conversation_history = conversation_history_after_compression(
agent, out.messages, out.conversation_history
)
_reset_retry_state_after_compaction(agent)
if not _compressor.should_compress(_preflight_tokens):
break
if not _tc._compression_warrants_another_preflight_pass(
_orig_tokens, _preflight_tokens, _compressor.threshold_tokens
):
out.blocked = True
logger.warning(
"Preflight compression made insufficient progress: "
"~%s -> ~%s request tokens; skipping additional passes",
f"{_orig_tokens:,}", f"{_preflight_tokens:,}",
)
# Sub-5% progress on a request still above the window: no further pass will get under it.
_tc._fail_closed_on_insufficient_progress(agent, _preflight_tokens)
break
def _engine_preflight_maintenance(
agent: Any, out: CompactionOutcome, _compressor: Any, _preflight_tokens: int,
system_message: Optional[str], effective_task_id: str,
) -> None:
"""Engine-driven sub-threshold preflight maintenance: engines overriding
``should_compress_preflight()`` get exactly ONE ``compress()`` pass; a no-op never
touches ``blocked``."""
_engine_preflight = getattr(_compressor, "should_compress_preflight", None)
if not callable(_engine_preflight):
return
try:
_wants_engine_preflight = bool(_engine_preflight(out.messages))
except Exception as _preflight_exc:
# A buggy engine must never break an otherwise-healthy turn.
logger.debug(
"should_compress_preflight raised %s; skipping "
"engine-driven preflight maintenance",
_preflight_exc,
)
return
if not _wants_engine_preflight:
return
logger.info(
"Engine-driven preflight maintenance: %s requested "
"compress() at ~%s tokens (below %s threshold)",
getattr(_compressor, "name", type(_compressor).__name__),
f"{_preflight_tokens:,}", f"{getattr(_compressor, 'threshold_tokens', 0):,}",
)
_engine_input = out.messages
out.messages, out.active_system_prompt = agent._compress_context(
_engine_input, system_message, approx_tokens=_preflight_tokens, task_id=effective_task_id
)
# ``_compress_context`` returns the INPUT list on every skip path and an engine
# may no-op; re-baseline/re-anchor only after a REAL compaction.
if out.messages is not _engine_input:
out.compressed = True
out.conversation_history = conversation_history_after_compression(
agent, out.messages
)
_reset_retry_state_after_compaction(agent)
def _rearm_uncompressed_overflow_warn(
agent: Any, messages: List[Any], active_system_prompt: Optional[str]
) -> None:
"""Uncompressed session guard: the warning fires from the loop's pre-API site;
here we only RE-ARM the dedup once back under the window."""
from agent import turn_context as _tc
_ctx_len = getattr(getattr(agent, "context_compressor", None), "context_length", None)
if not (isinstance(_ctx_len, int) and _ctx_len > 0):
return
_raw_chars = 0
for _m in messages:
if not isinstance(_m, dict):
continue
_c = _m.get("content")
if isinstance(_c, str):
_raw_chars += len(_c)
elif _c:
# Non-string, non-empty (multimodal) content defeats a char count — force
# the real estimate. None/"" contribute nothing.
_raw_chars = _ctx_len + 1
break
# Cheap gate: raw text under ~1/4 of the window (4 chars/token) cannot be over it.
if _raw_chars <= _ctx_len:
_clear_overflow_warn(agent)
return
# Re-arm with the same route-aware (checkpoint-pruned wire) figure the warn site
# measures, else a compacted session never clears the dedup and genuine overflow
# warnings stay suppressed.
_uncompressed_tokens = _tc._preflight_request_tokens(
agent, messages, active_system_prompt or ""
)
if _uncompressed_tokens <= _ctx_len:
_clear_overflow_warn(agent)