Files
hermes-agent/agent/native_compaction.py
Teknium 44982309b8 refactor(agent/prompt): dispatch tables and helper extraction in display, context refs, breakdown, compaction
build_tool_preview -> _PREVIEW_BUILDERS per-tool table; git @refs -> _GIT_REFERENCE_ARGS; context_breakdown
_skills_block/_append_overflow dedupe; prune_pre_checkpoint_items summary retention folded into one closure;
build_skill_invocation_message reuses _render_skill_block; ruff SIM collapses; restored two compacted
cache-policy invariant comments.
2026-09-02 13:53:58 -07:00

456 lines
18 KiB
Python

"""Native OpenAI Responses server-side compaction — gpt-5.6 on direct OpenAI routes only.
Including ``context_management=[{"type": "compaction", "compact_threshold": N}]``
in a ``/v1/responses`` request makes the server summarize older context into an
opaque ``compaction`` item (``encrypted_content``, sealed to the issuing
endpoint) once the input crosses N tokens; replaying that item stands in for
the pruned history. Docs: https://developers.openai.com/api/docs/guides/compaction
Support is deliberately narrow (live-verified):
* gpt-5.6 family only — gpt-5.1/5.2 fail server-side (HTTP 500 blocking, a
permanent stall streaming) with no structured "unsupported" rejection, so an
explicit model-family check is the only safe gate.
* Direct OpenAI routes only (api.openai.com or the ChatGPT Codex backend) —
other Responses surfaces would 400 on the field and cannot mint/decrypt the blob.
Hermes' local compressor stays armed as fallback owner: the native threshold is
clamped below the local trigger so the server compacts first, and captured
compaction items ride the existing ``codex_reasoning_items`` sidecar (persistence,
replay, cross-issuer stamping, kill switch). This module stays free of
transport/adapter imports so transport, adapter, and loop share the gate
without cycles; ``context_compressor`` and ``message_content`` sit below it.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from urllib.parse import urlsplit
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 many tokens below the local compressor's
# trigger so the server always 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"
def is_native_compaction_model(model: Optional[str]) -> bool:
"""True when the model is in the gpt-5.6 family."""
return _ELIGIBLE_MODEL_MARKER in (model or "").lower()
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
eligible = is_native_compaction_model(model) and (
direct_default
or is_direct_openai_route(base_url, is_codex_backend=is_codex_backend)
)
return {"native_compaction": eligible}
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 resolve_compact_threshold(
configured_threshold: Any,
local_trigger_tokens: Any = None,
) -> int:
"""Resolve automatic mode or clamp an explicit native threshold.
An omitted/invalid setting follows the local compressor trigger
(``ContextCompressor.threshold_tokens``) 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 = None
try:
if local_trigger_tokens is not None and not isinstance(local_trigger_tokens, bool):
local = int(local_trigger_tokens)
except (TypeError, ValueError):
local = None
if local is not None and local <= 0:
local = None
upper = None
if local is not None:
if local > LOCAL_TRIGGER_SAFETY_MARGIN:
upper = max(1_024, local - LOCAL_TRIGGER_SAFETY_MARGIN)
else:
upper = max(1_024, int(local * 0.8))
try:
configured = (
None
if isinstance(configured_threshold, (bool, float))
else int(configured_threshold)
)
except (TypeError, ValueError):
configured = None
if configured is None or configured <= 0:
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 _checkpoint_suppression_logged:
return
_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.
None means "do not send the field" (request byte-identical to pre-feature).
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``,
set by rejection recovery) takes effect on the next call.
"""
capabilities = getattr(agent, "runtime_capabilities", None)
if isinstance(capabilities, dict) and not capabilities.get("native_compaction", False):
return None
if not getattr(agent, "codex_responses_native_compaction", False):
return None
# compression.enabled: false disables ALL automatic compaction, native included.
if 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 Hermes
# 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:
return None
if not is_native_compaction_model(getattr(agent, "model", 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)
threshold = resolve_compact_threshold(
getattr(agent, "codex_responses_compact_threshold", None),
getattr(compressor, "threshold_tokens", None) if compressor is not None else None,
)
return [{"type": "compaction", "compact_threshold": threshold}]
# Retention budgets for plaintext user messages / local compression summaries
# carried across a native compaction boundary (mirrors Codex CLI's
# RETAINED_MESSAGE_TOKEN_BUDGET; the summary budget prevents summary inflation).
RETAINED_USER_MESSAGE_TOKEN_BUDGET = 64_000
RETAINED_SUMMARY_TOKEN_BUDGET = 32_000
def _approx_tokens(text: str) -> int:
"""Cheap chars//4 token estimate — same shape Codex uses for retention."""
return max(1, len(text) // 4)
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 isinstance(content, list):
parts = []
for part in content:
if isinstance(part, str):
if part.strip():
parts.append(part.strip())
elif isinstance(part, dict):
part_text = part.get("text") or part.get("input_text") or part.get("output_text")
if isinstance(part_text, str) and part_text.strip():
parts.append(part_text.strip())
part_meta = part.get("metadata")
if isinstance(part_meta, dict) and isinstance(part_meta.get("text"), str) and part_meta["text"].strip():
parts.append(part_meta["text"].strip())
text = " ".join(parts)
return text if text.strip() else None
return 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 ``input_image`` shape counts: unknown or empty
multipart placeholders must not become durable history for being non-empty.
"""
if not isinstance(item, dict):
return False
content = item.get("content")
if not isinstance(content, list):
return False
for part in content:
if not isinstance(part, dict):
continue
if str(part.get("type") or "").strip().lower() != "input_image":
continue
image_url = part.get("image_url")
if isinstance(image_url, str) and image_url.strip():
return True
return False
# Canonical provenance check (metadata marker, then canonical prefix classifier).
# Deliberately NOT a second heuristic: no underscore-key scan, no matching on
# ad-hoc headings — either could promote ordinary or adversarial content to
# durable retained history.
_is_summary_item = is_compaction_summary_message
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,
which silently erases the user's plaintext asks and any local-compression
summary (``role="assistant"``). With a checkpoint present, rebuild as::
[checkpoint run] + [retained user & summary messages (newest-first budget)] + [post]
- The NEWEST contiguous run of checkpoints wins.
- User messages are kept verbatim within ``retained_user_token_budget``;
the boundary message is head-truncated when it only partially fits
(string content only — goals are stated up front). A recognized
image-only user message is retained whole at one-token cost.
- Summaries are retained whole within ``retained_summary_token_budget`` and
never sliced (their structural framing would corrupt); one that doesn't
fit is dropped. Identical summary text is never retained twice.
- Relative order between user messages and summaries is preserved.
- ``item_sources`` (parallel to ``items``) is the raw chat message each item
was converted from. Conversion can be lossy for summaries (a
merge-into-tail carrier becomes a typed ``function_call_output``, or an
assistant carrier is shadowed by a stale exact replay), so when a source
is itself a canonical summary carrier its content is read from the
SOURCE and retained as a synthesized ``role="assistant"`` message.
- ``enable_summary_retention`` is a function-level override for tests, not
a config surface.
"""
if not isinstance(items, list) or not items:
return items
last_cp = None
for i, item in enumerate(items):
if isinstance(item, dict) and item.get("type") == "compaction":
last_cp = i
if last_cp is None:
return items
first_cp = last_cp
while (
first_cp > 0
and isinstance(items[first_cp - 1], dict)
and items[first_cp - 1].get("type") == "compaction"
):
first_cp -= 1
pre = items[:first_cp]
checkpoint_run = items[first_cp : last_cp + 1]
post = items[last_cp + 1 :]
if isinstance(item_sources, list) and len(item_sources) == len(items):
pre_sources: List[Any] = item_sources[:first_cp]
else:
pre_sources = [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."""
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:
return # never slice a summary's structural framing
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.
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)
has_retainable_image = is_user and _has_retainable_image_content(item)
if text is None and not has_retainable_image:
continue
if text is None:
text = ""
if is_summary:
_retain_summary(text, item)
elif is_user:
if user_remaining <= 0:
continue
cost = _approx_tokens(text)
if cost <= user_remaining:
retained_reversed.append(item)
user_remaining -= cost
elif isinstance(item.get("content"), str):
truncated = dict(item)
truncated["content"] = item["content"][: user_remaining * 4]
if truncated["content"].strip():
retained_reversed.append(truncated)
user_remaining = 0
result = checkpoint_run + list(reversed(retained_reversed)) + post
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 the loop's one-shot recovery (strip the field, disable for the
session, retry), so matching is narrow: a transient 5xx whose body merely
ECHOES the request must not permanently downgrade native compaction. Requires
``status_code`` 400 (or unknown — some transports surface only a message)
AND the field name alongside rejection language.
"""
text = str(error or "").lower()
if "context_management" not in text and "compact_threshold" not in text:
return False
if status_code is not None:
try:
if 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 ``type: "compaction"`` item is cumulative context, not per-turn
reasoning, and exists in exactly one place: anything that rewrites or
discards the sidecar must ask this first or lose the compacted history.
"""
return any(
isinstance(item, dict) and item.get("type") == "compaction"
for item in (items if isinstance(items, list) else ())
)
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 captured on the EARLIER response is not re-emitted by the
continuation, so a blind overwrite drops the only copy. Rule: newer items
win, but prior checkpoints are prepended unless the newer payload has its own.
"""
kept_checkpoints = [
item
for item in (prior_items if isinstance(prior_items, list) else [])
if isinstance(item, dict) and item.get("type") == "compaction"
]
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