Files
hermes-agent/agent/native_compaction.py
qqx 60b99e1932 fix(compression): enable native compaction for Astra 900K alias
(cherry picked from commit 8ce2851f0495db8afb44118928f4c3695bb12e06)
2026-09-26 19:27:41 +05:30

371 lines
18 KiB
Python

"""Native OpenAI Responses server-side compaction on supported OpenAI routes.
``context_management=[{"type": "compaction", "compact_threshold": N}]`` makes the server
summarize older context into an opaque ``compaction`` item once the input crosses N tokens.
Deliberately narrow: gpt-5.6 on api.openai.com or the ChatGPT Codex backend, plus exact
gpt-6-astra on official Codex OAuth. The local compressor
stays armed as fallback (native threshold clamped below the local trigger); compaction items
ride the ``codex_reasoning_items`` sidecar. No transport imports (shared gate, no cycles).
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from urllib.parse import urlsplit
from agent.codex_headers import is_official_codex_base_url
from agent.context_compressor import is_compaction_summary_message
from agent.message_content import flatten_message_text
logger = logging.getLogger(__name__)
# Native compaction fires this far below the local trigger so the server gets the first shot.
LOCAL_TRIGGER_SAFETY_MARGIN = 8_192
# Fallback when automatic mode has no local trigger to follow.
DEFAULT_COMPACT_THRESHOLD = 200_000
# Substring match so dated snapshots and variants (gpt-5.6-mini) stay eligible.
_ELIGIBLE_MODEL_MARKER = "gpt-5.6"
_ASTRA_NATIVE_COMPACTION_MODELS = frozenset({"gpt-6-astra", "gpt-6-astra-900k"})
def is_native_compaction_model(
model: Optional[str], *, provider: Optional[str] = None, base_url: Optional[str] = None,
) -> bool:
"""Preserve gpt-5.6 eligibility; Astra additionally requires official Codex OAuth.
``-900k`` is Hermes' local picker alias and is stripped before the request is
sent. It must retain the same native-compaction eligibility as the bare
Astra wire model; otherwise enabling the feature in a 900K session is a
silent no-op.
"""
model_name = (model or "").strip().lower().rsplit("/", 1)[-1]
return _ELIGIBLE_MODEL_MARKER in model_name or (
model_name in _ASTRA_NATIVE_COMPACTION_MODELS
and (provider or "").strip().lower() == "openai-codex"
and is_official_codex_base_url(base_url or "")
)
def resolve_native_compaction_capabilities(
*, model: Optional[str], base_url: Optional[str], provider: Optional[str] = None, is_codex_backend: bool = False,
) -> Dict[str, bool]:
"""Resolve the native-compaction capability for a runtime destination (a resolved ``False``
is distinct from "unresolved" and must survive model switches unchanged)."""
direct_default = (provider or "").strip().lower() == "openai" and not base_url
return {"native_compaction": is_native_compaction_model(model, provider=provider, base_url=base_url) and (
direct_default or is_direct_openai_route(base_url, is_codex_backend=is_codex_backend))}
def is_direct_openai_route(base_url: Optional[str], *, is_codex_backend: bool = False) -> bool:
"""True for api.openai.com or the ChatGPT Codex backend — nothing else."""
if is_codex_backend:
return True
try:
hostname = (urlsplit(base_url or "").hostname or "").lower()
except ValueError:
return False
return hostname == "api.openai.com"
def _positive_int(value: Any, *, reject: tuple = (bool,)) -> Optional[int]:
"""``int(value)`` when it is a positive integer-like (never a bool), else None."""
if value is None or isinstance(value, reject):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def resolve_compact_threshold(configured_threshold: Any, local_trigger_tokens: Any = None) -> int:
"""Resolve automatic mode or clamp an explicit native threshold.
Omitted/invalid follows the local compressor trigger minus the safety margin. An
explicit positive integer is absolute unless it must be clamped so native compaction
fires first. Booleans are never thresholds.
"""
local = _positive_int(local_trigger_tokens)
upper = None if local is None else max(
1_024, local - LOCAL_TRIGGER_SAFETY_MARGIN if local > LOCAL_TRIGGER_SAFETY_MARGIN else int(local * 0.8))
configured = _positive_int(configured_threshold, reject=(bool, float))
if configured is None:
return upper if upper is not None else DEFAULT_COMPACT_THRESHOLD
if upper is None:
return configured
return max(1_024, min(configured, upper))
_checkpoint_suppression_logged = False
def _warn_native_compaction_suppressed_by_checkpoint_gate() -> None:
"""Log once per process; the suppression itself is re-evaluated per request."""
global _checkpoint_suppression_logged
if not _checkpoint_suppression_logged:
_checkpoint_suppression_logged = True
logger.warning(
"compression.checkpoint_required is enabled: server-side native "
"compaction (context_management) is disabled for this agent so the "
"checkpoint-aware Hermes compressor stays authoritative."
)
def native_compaction_context_management(agent: Any, *, is_codex_backend: bool, is_xai_responses: bool = False,
is_github_responses: bool = False) -> Optional[List[Dict[str, Any]]]:
"""Return the ``context_management`` payload for this request, or None ("do not send").
Every gate is re-checked per request so a mid-session model switch or the in-session
kill switch (``agent.codex_responses_native_compaction = False``) takes effect next call.
"""
capabilities = getattr(agent, "runtime_capabilities", None)
if isinstance(capabilities, dict) and not capabilities.get("native_compaction", False):
return None
# compression.enabled: false disables ALL automatic compaction, native included.
if not getattr(agent, "codex_responses_native_compaction", False) or not getattr(agent, "compression_enabled", True):
return None
# Server-side compaction is a lossy boundary the provider owns (no pre-compress checkpoint
# can run first), so the checkpoint-aware compressor stays authoritative. Explicit-True
# matches compress_context().
if getattr(agent, "compression_checkpoint_required", False) is True:
_warn_native_compaction_suppressed_by_checkpoint_gate()
return None
if is_xai_responses or is_github_responses or not is_native_compaction_model(
getattr(agent, "model", None), provider=getattr(agent, "provider", None),
base_url=getattr(agent, "base_url", None),
):
return None
trusted_proxy = bool(getattr(agent, "capabilities", {}).get("openai_native_compaction", False))
if not trusted_proxy and not is_direct_openai_route(getattr(agent, "base_url", None), is_codex_backend=is_codex_backend):
return None
compressor = getattr(agent, "context_compressor", None)
local_trigger = getattr(compressor, "threshold_tokens", None) if compressor is not None else None
threshold = resolve_compact_threshold(getattr(agent, "codex_responses_compact_threshold", None), local_trigger)
return [{"type": "compaction", "compact_threshold": threshold}]
# Retention budgets for plaintext user messages / local summaries carried across a native
# compaction boundary (mirrors Codex CLI's RETAINED_MESSAGE_TOKEN_BUDGET).
RETAINED_USER_MESSAGE_TOKEN_BUDGET = 64_000
RETAINED_SUMMARY_TOKEN_BUDGET = 32_000
def _approx_tokens(text: str) -> int:
"""Retention cost of one carried-over text; never 0 so an empty item still consumes budget."""
from agent.model_metadata import estimate_tokens_rough
return max(1, estimate_tokens_rough(text))
def _extract_item_text(item: Any) -> Optional[str]:
"""Measurable text from a Responses item (string/multipart/metadata), or None."""
if not isinstance(item, dict):
return None
content = item.get("content")
if content is None and "output_text" in item:
content = item.get("output_text")
if isinstance(content, str):
return content if content.strip() else None
if not isinstance(content, list):
return None
parts = []
for part in content:
candidates: tuple = (part,) # non-str, non-dict parts filter out below
if isinstance(part, dict):
part_meta = part.get("metadata")
candidates = (part.get("text") or part.get("input_text") or part.get("output_text"),
part_meta.get("text") if isinstance(part_meta, dict) else None)
parts.extend(c.strip() for c in candidates if isinstance(c, str) and c.strip())
text = " ".join(parts)
return text if text.strip() else None
def _has_retainable_image_content(item: Any) -> bool:
"""True for a converted Responses message with a valid ``input_image`` part (only the
adapter-owned shape counts, so empty multipart placeholders never become durable history)."""
content = item.get("content") if isinstance(item, dict) else None
return isinstance(content, list) and any(
isinstance(part, dict) and str(part.get("type") or "").strip().lower() == "input_image"
and isinstance(part.get("image_url"), str) and part["image_url"].strip() for part in content
)
# Canonical provenance check. Deliberately NOT a second heuristic (no underscore-key scan,
# no ad-hoc headings) — either could promote adversarial content to durable history.
_is_summary_item = is_compaction_summary_message
def _is_compaction_item(item: Any) -> bool:
return isinstance(item, dict) and item.get("type") == "compaction"
def prune_pre_checkpoint_items(
items: List[Dict[str, Any]],
retained_user_token_budget: int = RETAINED_USER_MESSAGE_TOKEN_BUDGET,
retained_summary_token_budget: int = RETAINED_SUMMARY_TOKEN_BUDGET,
enable_summary_retention: bool = True, item_sources: Optional[List[Any]] = None,
) -> List[Dict[str, Any]]:
"""Restructure Responses input around the newest compaction checkpoint.
The server drops every input item preceding a replayed ``compaction`` item, erasing the
user's plaintext asks and any local-compression summary. Rebuild as::
[checkpoint run] + [retained user & summary messages (newest-first budget)] + [post]
- The NEWEST contiguous run of checkpoints wins; relative order is preserved.
- User messages are kept verbatim within ``retained_user_token_budget``; the boundary
message is head-truncated when it only partially fits (string content only). A
recognized image-only user message is retained whole at one-token cost.
- Summaries are retained whole within ``retained_summary_token_budget``, never sliced
(framing would corrupt) and never duplicated.
- ``item_sources`` (parallel to ``items``) is the raw chat message each item came from.
Conversion can be lossy for summaries (merge-into-tail carrier → typed
``function_call_output``; assistant carrier shadowed by a stale replay), so a source
that is itself a canonical summary carrier is read from the SOURCE and retained as a
synthesized ``role="assistant"`` message.
- ``enable_summary_retention`` is a test override, not a config surface.
The server drops every input item that precedes a replayed ``compaction`` item (live-verified Aug 2026),
so sending pre-checkpoint history is dead weight AND silently erases the user's plaintext asks —
including any local-compression summary the agent already produced, which previously vanished here
because it carries ``role="assistant"``, not ``"user"`` (#90975).
A summary is never byte/character-sliced: Hermes summaries carry structural framing (handoff prefix, end
marker, merge-into-tail delimiters) that a blind slice can corrupt, so one that doesn't fit whole is
dropped instead. A summary already retained once (identical text) is never duplicated, so repeated
checkpoints stay idempotent. - ``enable_summary_retention`` is a function-level override (used by tests
and callers that need the pre-#90975 behavior back); it is not wired to a user-facing config surface.
Without ``item_sources`` (default), retention only sees what survived conversion, matching pre-#90976
behavior (#90976).
"""
if not isinstance(items, list) or not items:
return items
last_cp = max((i for i, item in enumerate(items) if _is_compaction_item(item)), default=None)
if last_cp is None:
return items
first_cp = last_cp
while first_cp > 0 and _is_compaction_item(items[first_cp - 1]):
first_cp -= 1
pre = items[:first_cp]
has_sources = isinstance(item_sources, list) and len(item_sources) == len(items)
pre_sources: List[Any] = item_sources[:first_cp] if has_sources else [None] * len(pre)
retained_reversed: List[Dict[str, Any]] = []
user_remaining = max(0, int(retained_user_token_budget))
summary_remaining = max(0, int(retained_summary_token_budget))
seen_summary_texts: set = set()
def _retain_summary(text: Optional[str], retained_item: Dict[str, Any]) -> None:
"""Retain a summary whole when it fits the budget and is not a duplicate (never sliced)."""
nonlocal summary_remaining
if not text or summary_remaining <= 0 or text in seen_summary_texts:
return
cost = _approx_tokens(text)
if cost <= summary_remaining:
seen_summary_texts.add(text)
retained_reversed.append(retained_item)
summary_remaining -= cost
for item, source in zip(reversed(pre), reversed(pre_sources)):
if not isinstance(item, dict):
continue
# Source-based detection sees past a lossy conversion; it only fires
# when the source itself is a provenance-tagged summary carrier.
# Canonical source-based summary detection: reads the ORIGINAL chat message's own content, so it
# sees past a lossy conversion (a typed `function_call_output` wrapper, or a stale exact-replay
# message) that erased the summary from `item` itself (#90976).
if enable_summary_retention and isinstance(source, dict) and _is_summary_item(source):
text = flatten_message_text(source.get("content"))
_src_role = source.get("role")
_retain_summary(text if text.strip() else None,
{"role": _src_role if _src_role in ("user", "assistant") else "assistant", "content": text})
continue
# Typed non-message items never carry role=user or a summary flag.
if "type" in item and item.get("type") != "message":
continue
is_summary = enable_summary_retention and _is_summary_item(item)
is_user = item.get("role") == "user"
if not is_user and not is_summary:
continue
text = _extract_item_text(item)
if text is None:
if not (is_user and _has_retainable_image_content(item)):
continue
text = ""
if is_summary:
_retain_summary(text, item)
elif user_remaining > 0:
cost = _approx_tokens(text)
if cost <= user_remaining:
retained_reversed.append(item)
user_remaining -= cost
elif isinstance(item.get("content"), str):
truncated = {**item, "content": item["content"][: user_remaining * 4]}
if truncated["content"].strip():
retained_reversed.append(truncated)
user_remaining = 0
result = items[first_cp : last_cp + 1] + list(reversed(retained_reversed)) + items[last_cp + 1 :]
logger.debug("Pruned pre-checkpoint items: %d input -> %d retained (user_rem=%d, summary_rem=%d)",
len(items), len(result), user_remaining, summary_remaining)
return result
_REJECTION_MARKERS = (
"unknown", "unsupported", "invalid", "unexpected", "not permitted",
"not allowed", "unrecognized", "extra field", "no such", "bad request",
"not supported",
)
def is_native_compaction_rejection(error: Any, status_code: Any = None) -> bool:
"""True when a provider error is a STRUCTURED rejection of ``context_management``.
Drives one-shot recovery (strip, disable for the session, retry), so matching is narrow:
a transient 5xx that merely ECHOES the request must not downgrade native compaction.
Requires ``status_code`` 400 (or unknown) AND the field name with rejection language.
See #82777.
* ``status_code`` is 400 (or unknown/None — some transports surface only a message string; field-name
matching alone is then the best available signal, preserving pre-#82777 behavior for them), and * the
error text names ``context_management`` / ``compact_threshold`` alongside rejection language ("unknown",
"unsupported", "invalid", "unexpected", "not permitted"...). A bare field-name echo without rejection
language does not match.
"""
text = str(error or "").lower()
if "context_management" not in text and "compact_threshold" not in text:
return False
try:
if status_code is not None and int(status_code) != 400:
return False
except (TypeError, ValueError):
pass
return any(marker in text for marker in _REJECTION_MARKERS)
def has_compaction_checkpoint(items: Any) -> bool:
"""Does this ``codex_reasoning_items`` sidecar carry a compaction checkpoint? A checkpoint is
cumulative context living in exactly one place: rewrite/discard the sidecar only after asking."""
return isinstance(items, list) and any(
_is_compaction_item(item)
and isinstance(item.get("encrypted_content"), str)
and bool(item["encrypted_content"].strip())
for item in items
)
def merge_interim_reasoning_items(prior_items: Any, new_items: Any) -> List[Dict[str, Any]]:
"""Merge ``codex_reasoning_items`` across Codex incomplete-continuation dedup.
A checkpoint on the EARLIER response is not re-emitted by the continuation, so a blind
overwrite drops the only copy: newer items win, prior checkpoints are prepended unless
the newer payload has its own.
"""
prior = prior_items if isinstance(prior_items, list) else []
kept_checkpoints = [item for item in prior if _is_compaction_item(item)]
new_list = list(new_items) if isinstance(new_items, list) else []
if has_compaction_checkpoint(new_list) or not kept_checkpoints:
return new_list
return kept_checkpoints + new_list