Files
hermes-agent/agent/conversation_compression_codex.py
mnajafian-nv 4c7ab8b2a0 fix(compression): name the automatic trigger and effect in attempt telemetry
The content-free attempt record is the one place that says why a
compaction ran and what it did, but it could not answer either question:

- Every automatic caller passed no trigger, so idle, turn-start
  threshold, engine preflight, pre-API, post-tool and gateway hygiene
  attempts all logged trigger_source=auto. Each call site now passes its
  own label; the shared metric still buckets them as auto.
- tokens after, message counts and how the summary was made were
  computed and logged as prose but never reached the record. A finished
  rewrite now records messages_before/after, tokens_before/after,
  tokens_reclaimed (signed; a summary can grow a short transcript),
  token_count_method=estimate_rough, method (llm_summary,
  aux_fallback_main, deterministic_fallback), items_dropped, and the
  tool_results_pruned / reasoning_items_pruned pass counts.
- Attempts that never reached the local compressor returned silently:
  the automatic breaker gate (before and under the lease), every Codex
  app-server exit, a lease that found the session already rotated, and
  an unreadable durable cooldown. Each now logs one record with a
  commit_status (blocked, skipped, failed, aborted, committed), a route,
  and a class such as blocked:cooldown. These stay out of shared metrics,
  which never counted them.

Test doubles for _compress_context gain the trigger keyword the call
sites now pass.

Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
(cherry picked from commit 1795993d1a01d91dd76e6ec824cb606f8a03b897)
2026-10-10 04:27:27 -07:00

135 lines
7.0 KiB
Python

"""Codex app-server compaction: the route ``compress_context`` takes for Codex-owned threads.
Split from ``agent/conversation_compression.py`` (facade size cap), which imports it at module level;
this module must never import the facade at module level (import cycle), so it late-imports the
facade's shared helpers inside functions. It logs under the facade's logger name so log consumers keep
one source.
"""
from __future__ import annotations
import contextlib
import logging
from typing import Any, Optional
from agent.conversation_compression_telemetry import _emit_bypassed_attempt_telemetry
logger = logging.getLogger("agent.conversation_compression")
def _codex_compaction_cooldown_remaining(agent: Any) -> float:
"""Seconds left on this session's compaction-failure cooldown (0 = clear)."""
compressor = getattr(agent, "context_compressor", None)
getter = getattr(compressor, "get_active_compression_failure_cooldown", None)
if not callable(getter):
return 0.0
try:
state = getter(refresh=True)
except Exception:
logger.debug("codex compaction cooldown lookup failed", exc_info=True)
return 0.0
try:
return max(0.0, float(state.get("remaining_seconds") or 0.0)) if state else 0.0
except (TypeError, ValueError):
return 0.0
def _record_codex_compaction_failure(agent: Any, error: str) -> None:
"""Arm the shared compression-failure cooldown after a failed codex compaction.
The codex path returns the transcript unchanged, so without a cooldown the still-over-threshold session
would retry every turn."""
from agent.context_compressor import _SUMMARY_FAILURE_COOLDOWN_SECONDS
from agent.conversation_compression import _swallow
compressor = getattr(agent, "context_compressor", None)
recorder = getattr(compressor, "_record_compression_failure_cooldown", None)
if not callable(recorder):
return
with _swallow('codex compaction cooldown persist failed', exc_info=True):
recorder(_SUMMARY_FAILURE_COOLDOWN_SECONDS, error)
def _compress_context_via_codex_app_server(
agent: Any, messages: list, system_message: Optional[str], *, approx_tokens: Optional[int] = None,
task_id: str = "default", force: bool = False, started_at: float,
) -> tuple[list, str]:
"""Route compaction to Codex app-server for Codex-owned threads.
Rewriting the local transcript would not shrink the Codex thread, so Codex compacts its own thread and
Hermes' transcript is left unchanged. Every returning exit logs one attempt record."""
from agent.conversation_compression import (
COMPACTION_STATUS, _CompressionActivityHeartbeat, _emit_compaction_done, _existing_system_prompt,
_reset_read_dedup_caches, _swallow,
)
def _record(commit_status: str, failure_class: Optional[str], method: str = "none") -> None:
_emit_bypassed_attempt_telemetry(
agent, started_at, commit_status=commit_status, failure_class=failure_class, approx_tokens=approx_tokens,
route="codex_app_server", method=method,
)
_sid = getattr(agent, "session_id", None) or "none"
_tokens = f"{approx_tokens:,}" if approx_tokens else "unknown"
auto_mode = str(getattr(agent, "codex_app_server_auto_compaction", "native") or "native").lower()
if auto_mode not in {"native", "hermes", "off"}:
auto_mode = "native"
skip_reason = skip_status = skip_class = None
if not force and auto_mode != "hermes":
skip_reason, skip_status, skip_class = f"mode={auto_mode} force=false", "skipped", f"codex_auto_{auto_mode}"
elif not force:
# Automatic entrypoints honor the compressor-owned cooldown: a recent compaction
# failed, and retrying every turn is what thrashes.
_cooldown_remaining = _codex_compaction_cooldown_remaining(agent)
if _cooldown_remaining > 0:
skip_reason = f"failure cooldown active for {_cooldown_remaining:.0f}s"
skip_status, skip_class = "blocked", "blocked:cooldown"
codex_session = getattr(agent, "_codex_session", None)
if skip_reason is None and codex_session is None:
skip_reason, skip_status, skip_class = "no active codex thread", "skipped", "codex_no_thread"
if skip_reason is not None:
logger.info(
"codex app-server compaction skipped: %s (session=%s messages=%d tokens=~%s)", skip_reason, _sid,
len(messages), _tokens,
)
_record(skip_status, skip_class)
return messages, _existing_system_prompt(agent, system_message)
logger.info("codex app-server compaction started: session=%s messages=%d tokens=~%s", _sid, len(messages), _tokens)
with contextlib.suppress(Exception):
agent._emit_status(COMPACTION_STATUS)
_activity_heartbeat = _CompressionActivityHeartbeat(agent, emit_client_status=True).start()
try:
result = codex_session.compact_thread()
except BaseException:
_activity_heartbeat.stop("context compression failed")
raise
failed = bool(getattr(result, "interrupted", False) or getattr(result, "error", None))
_activity_heartbeat.stop("context compression failed" if failed else "context compression completed")
if getattr(result, "should_retire", False):
with contextlib.suppress(Exception):
codex_session.close()
agent._codex_session = None
if failed:
with contextlib.suppress(Exception):
agent._emit_warning(f"⚠ Codex app-server compaction failed: {result.error}")
# The transcript is returned unchanged, so the session is still over
# threshold. Without a brake the next turn retries immediately.
_record_codex_compaction_failure(agent, str(getattr(result, "error", None) or "compaction interrupted"))
_record("failed", "codex_compaction_failed")
return messages, _existing_system_prompt(agent, system_message)
with _swallow('codex compaction bookkeeping failed', exc_info=True):
from agent.codex_runtime import _record_codex_app_server_compaction, _record_codex_app_server_usage
_record_codex_app_server_compaction(agent, result, approx_tokens=approx_tokens, force=True)
# An empty usage report must consume the pending verdict, not leave deferral
# armed until a later turn; minimal test engines may lack update_from_response.
if hasattr(agent.context_compressor, "update_from_response"):
_record_codex_app_server_usage(agent, result, messages=messages)
_reset_read_dedup_caches(task_id, session_id=agent.session_id or "")
logger.info(
"codex app-server compaction done: session=%s thread=%s turn=%s", _sid,
getattr(result, "thread_id", None) or "", getattr(result, "turn_id", None) or "",
)
existing_prompt = _existing_system_prompt(agent, system_message)
_record("committed", None, method="provider")
# Terminal edge only on success — failure/interrupt paths above return
# without it, matching the main compress_context() gating.
_emit_compaction_done(agent)
return messages, existing_prompt