Files
hermes-agent/agent/turn_context_compaction.py

479 lines
20 KiB
Python

"""Turn-start compaction for ``build_turn_context``.
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`` with the rebuilt locals.
Predicates and estimators that tests patch on ``agent.turn_context`` are imported
lazily through that module so the patches keep intercepting."""
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
def _clear_overflow_warn(agent: Any) -> None:
"""Re-arm the context-overflow warning dedup. getattr guard: ``object.__new__``
test doubles lack the method."""
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
if callable(_clear_warn):
_clear_warn()
def _reset_retry_state_after_compaction(agent: Any) -> None:
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 _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
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``, complementing the token
gate; a cheap gap check gates the estimate (cf. ``_should_run_preflight_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
# Route-aware pressure: on compacted native-Codex sessions the durable figure
# overstates the wire; reuse the preflight estimator (#96995).
_idle_tokens = _tc._preflight_request_tokens(
agent, messages, out.active_system_prompt or ""
)
# Post-compression target size: don't summarise a thread already below what
# compaction would reduce it to.
_idle_floor = int(_compressor.threshold_tokens * _compressor.summary_target_ratio)
_idle_cooldown = getattr(
_compressor, "get_active_compression_failure_cooldown", lambda: None
)()
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),
):
return
logger.info(
"Idle compaction: %ss idle >= %ss, ~%s tokens > %s floor (session %s)",
int(_idle_gap), _idle_after, f"{_idle_tokens:,}", f"{_idle_floor:,}",
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 (#36801)."""
return (
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, #27405)."""
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
if _tc._review_fork_first_request_pending(agent) or not _tc._should_run_preflight_estimate(
out.messages,
agent.context_compressor.protect_first_n,
agent.context_compressor.protect_last_n,
agent.context_compressor.threshold_tokens,
):
return
messages = out.messages
_preflight_tokens = _tc._preflight_request_tokens(
agent, messages, out.active_system_prompt or ""
)
_compressor = agent.context_compressor
# 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
_defer_preflight = getattr(
_compressor, "should_defer_preflight_to_real_usage", lambda _tokens: False
)
_preflight_deferred = _defer_preflight(_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 (#36718). 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, "
"but last real provider prompt was %s after compression",
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:
# Over threshold but blocked: ask should_compress_info for the reason to
# surface below. getattr guard: doubles/older engines lack it.
_info = getattr(_compressor, "should_compress_info", None)
if callable(_info):
try:
_compress_block_reason = _info(_preflight_tokens)[1]
except Exception:
_compress_block_reason = None
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:
_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)"
)
_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 (#20316).
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 (#69870): 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(
"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 (#39548).
_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)
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:,}",
)
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 (#20316): 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: swallow at debug
# level and skip maintenance.
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 (#89297): 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 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;
# non-string (multimodal) content forces the real estimate.
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 (#96995/#97602).
_uncompressed_tokens = _tc._preflight_request_tokens(
agent, messages, active_system_prompt or ""
)
if _uncompressed_tokens <= _ctx_len:
_clear_overflow_warn(agent)