Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2078,6 +2078,27 @@ def _parse_prune_int(raw, default):
codex_app_server_auto_compaction,
)
codex_app_server_auto_compaction = "native"
# Native OpenAI Responses server-side compaction (opt-in). Only ever
# engages for gpt-5.6-family models on api.openai.com or the ChatGPT
# Codex backend — the per-request gate lives in agent/native_compaction.py.
codex_responses_native_compaction = bool(
_compression_cfg.get("codex_responses_native", False)
)
_native_threshold_raw = _compression_cfg.get(
"codex_responses_compact_threshold", 200_000
)
try:
if isinstance(_native_threshold_raw, bool):
raise ValueError
codex_responses_compact_threshold = int(_native_threshold_raw)
if codex_responses_compact_threshold <= 0:
raise ValueError
except (TypeError, ValueError):
_ra().logger.warning(
"Invalid compression.codex_responses_compact_threshold=%r; using 200000.",
_native_threshold_raw,
)
codex_responses_compact_threshold = 200_000
# Opt-in idle compaction: compact a session up front when it resumes after
# this many seconds of inactivity (0 = disabled). Time-based, so it
# complements the size-based threshold above. Consumed by build_turn_context().
Expand Down Expand Up @@ -2534,6 +2555,8 @@ def _parse_prune_int(raw, default):
compression_micro_compact_defrag_tokens
)
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
agent.codex_responses_native_compaction = codex_responses_native_compaction
agent.codex_responses_compact_threshold = codex_responses_compact_threshold
agent.max_compression_attempts = compression_max_attempts
agent.compression_idle_compact_after_seconds = (
compression_idle_compact_after_seconds
Expand Down
12 changes: 12 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,17 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
is_xai_responses = agent.provider in {"xai", "xai-oauth"} or agent._base_url_hostname == "api.x.ai"
_msgs_for_codex = agent._prepare_messages_for_non_vision_model(api_messages)

# Native server-side compaction (gpt-5.6 on direct OpenAI API /
# ChatGPT Codex routes only) — None on every other route/model, in
# which case the request is unchanged from pre-feature behavior.
from agent.native_compaction import native_compaction_context_management
_context_management = native_compaction_context_management(
agent,
is_codex_backend=is_codex_backend,
is_xai_responses=is_xai_responses,
is_github_responses=is_github_responses,
)

# xAI's /responses endpoint rejects ``pattern`` and ``format`` keywords
# in tool schemas (HTTP 400 "Invalid arguments passed to the model").
# Most commonly hit when MCP-derived tools carry JSON Schema validation
Expand Down Expand Up @@ -1441,6 +1452,7 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
replay_encrypted_reasoning=bool(
getattr(agent, "_codex_reasoning_replay_enabled", True)
),
context_management=_context_management,
)

# ── chat_completions (default) ─────────────────────────────────────
Expand Down
37 changes: 36 additions & 1 deletion agent/codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,17 @@ def _preflight_codex_input_items(
normalized.append(reasoning_item)
continue

if item_type == "compaction":
# Replayed native server-side compaction checkpoint (gpt-5.6,
# direct OpenAI/Codex routes). Opaque, issuer-sealed; forward
# only the fields the API defines.
encrypted = item.get("encrypted_content")
if isinstance(encrypted, str) and encrypted:
normalized.append(
{"type": "compaction", "encrypted_content": encrypted}
)
continue

if item_type == "message":
role = item.get("role")
if role != "assistant":
Expand Down Expand Up @@ -1030,7 +1041,7 @@ def _preflight_codex_api_kwargs(
"model", "instructions", "input", "tools", "store",
"reasoning", "include", "max_output_tokens", "temperature",
"tool_choice", "parallel_tool_calls", "prompt_cache_key",
"prompt_cache_retention", "service_tier",
"prompt_cache_retention", "service_tier", "context_management",
"extra_headers", "extra_body", "timeout",
}
normalized: Dict[str, Any] = {
Expand Down Expand Up @@ -1079,6 +1090,13 @@ def _preflight_codex_api_kwargs(
if val is not None:
normalized[passthrough_key] = val

# Native server-side compaction directive (gpt-5.6 on direct OpenAI /
# Codex routes — eligibility already resolved upstream in
# agent/native_compaction.py; the preflight only preserves the shape).
context_management = api_kwargs.get("context_management")
if isinstance(context_management, list) and context_management:
normalized["context_management"] = context_management

extra_headers = api_kwargs.get("extra_headers")
if extra_headers is not None:
if not isinstance(extra_headers, dict):
Expand Down Expand Up @@ -1416,6 +1434,23 @@ def _normalize_codex_response(
raw_summary.append({"type": "summary_text", "text": text})
raw_item["summary"] = raw_summary
reasoning_items_raw.append(raw_item)
elif item_type == "compaction":
# Native server-side compaction checkpoint (gpt-5.6 on direct
# OpenAI/Codex routes). The encrypted blob stands in for the
# pruned older context on subsequent requests. It rides the
# codex_reasoning_items sidecar so it inherits persistence
# (state.db), session replay, the cross-issuer guard, and the
# invalid-encrypted-content kill switch without new state.
encrypted = getattr(item, "encrypted_content", None)
if isinstance(encrypted, str) and encrypted:
raw_item = {"type": "compaction", "encrypted_content": encrypted}
if issuer_kind:
raw_item["_issuer_kind"] = issuer_kind
reasoning_items_raw.append(raw_item)
logger.info(
"Native Responses compaction item captured (%d chars encrypted).",
len(encrypted),
)
elif item_type == "function_call":
if item_status in {"queued", "in_progress", "incomplete"}:
continue
Expand Down
31 changes: 31 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -4368,6 +4368,37 @@ def _perform_api_call(next_api_kwargs):
)
continue

# ── Native compaction rejection recovery ──────────────
# Provider explicitly rejected the ``context_management``
# field (structured 400 naming the param). One-shot: turn
# native compaction off for the rest of the session and
# retry — the next _build_api_kwargs re-resolves the gate
# and omits the field, and Hermes' local compression takes
# over as the sole owner. Generic 4xx/5xx/timeouts do NOT
# match (see is_native_compaction_rejection) and take the
# normal retry path.
if (
agent.api_mode == "codex_responses"
and not _retry.native_compaction_reject_retry_attempted
and bool(getattr(agent, "codex_responses_native_compaction", False))
):
from agent.native_compaction import is_native_compaction_rejection
if is_native_compaction_rejection(api_error):
_retry.native_compaction_reject_retry_attempted = True
agent.codex_responses_native_compaction = False
agent._vprint(
f"{agent.log_prefix}⚠️ Provider rejected native compaction "
f"(context_management) — disabled for this session, "
f"local compression stays active. Retrying...",
force=True,
)
logger.warning(
"%sNative compaction rejection recovery: disabled "
"codex_responses_native for this session and retrying",
agent.log_prefix,
)
continue

# ── llama.cpp grammar-parse recovery ──────────────────
# llama.cpp's ``json-schema-to-grammar`` converter rejects
# regex escape classes (``\d``, ``\w``, ``\s``) and most
Expand Down
156 changes: 156 additions & 0 deletions agent/native_compaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Native OpenAI Responses server-side compaction — gpt-5.6 on direct OpenAI routes only.

OpenAI's Responses API supports server-side compaction: include
``context_management=[{"type": "compaction", "compact_threshold": N}]`` in a
``/v1/responses`` request and, when the rendered input crosses N tokens, the
server summarizes older context into an opaque ``compaction`` output item
(``encrypted_content``, sealed to the issuing endpoint). Replaying that item
as an input item on later requests stands in for the pruned history, so the
model keeps long-horizon recall without the client ever seeing a summary.
Docs: https://developers.openai.com/api/docs/guides/compaction

Hermes' support is deliberately narrow (live verification, Aug 2026):

* **gpt-5.6 family only.** gpt-5.6 and its variants compact correctly.
Sending the field to gpt-5.1 / gpt-5.2 reliably fails server-side —
HTTP 500 on the blocking path and a permanent stall on the streaming
path (90s watchdog x 3 retries = a dead turn). There is no structured
"unsupported" rejection to downgrade on, so the only safe gate is an
explicit model-family check.
* **Direct OpenAI routes only:** api.openai.com (API key) or the ChatGPT
Codex backend (subscription OAuth). Every other Responses surface
(xAI, GitHub/Copilot, relays, local servers) never sees the field —
most would 400 on the unknown parameter, and none can mint or decrypt
the compaction blob.

Ownership model: Hermes' local compression stays fully armed as the
fallback owner. The native threshold is clamped safely below the local
compressor's trigger so the server compacts first; if it doesn't (native
disabled mid-session, provider hiccup, non-eligible route), the local
summarizer fires exactly as before. There is no new custody state — the
captured compaction items ride the existing ``codex_reasoning_items``
sidecar, which already handles persistence (state.db), gateway session
replay, cross-issuer stamping, and the encrypted-replay kill switch.

This module is dependency-free on purpose so the transport, adapter, and
conversation loop can share the gate without import cycles.
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional
from urllib.parse import urlsplit

# Native compaction fires this many tokens below the local compressor's
# trigger so the server always gets the first shot at compaction.
LOCAL_TRIGGER_SAFETY_MARGIN = 8_192

DEFAULT_COMPACT_THRESHOLD = 200_000

# Model-family gate. Substring match on the lowercased model id so dated
# snapshots (gpt-5.6-2026-07-xx) 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 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:
"""Clamp the configured native threshold below the local compressor trigger.

Without the clamp a native threshold above the local trigger would let the
local summarizer fire first every time, making native compaction dead
config. ``local_trigger_tokens`` is ``ContextCompressor.threshold_tokens``
when a compressor is attached, else None.
"""
try:
configured = int(configured_threshold)
except (TypeError, ValueError):
configured = DEFAULT_COMPACT_THRESHOLD
if isinstance(configured_threshold, bool) or configured <= 0:
configured = DEFAULT_COMPACT_THRESHOLD

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 None or local <= 0:
return configured

if local > LOCAL_TRIGGER_SAFETY_MARGIN:
upper = local - LOCAL_TRIGGER_SAFETY_MARGIN
else:
upper = max(1_024, int(local * 0.8))
return max(1_024, min(configured, upper))


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" — the request is byte-identical to
pre-feature behavior. All gates are re-checked per request so a
mid-session model switch or the in-session kill switch
(``agent.codex_responses_native_compaction = False``, set by the
conversation loop's rejection recovery) takes effect on the next call.
"""
if not bool(getattr(agent, "codex_responses_native_compaction", False)):
return None
# compression.enabled: false disables ALL automatic compaction, native
# included — mirrors the codex_app_server_auto contract.
if not bool(getattr(agent, "compression_enabled", True)):
return None
if is_xai_responses or is_github_responses:
return None
if not is_native_compaction_model(getattr(agent, "model", None)):
return None
if 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", DEFAULT_COMPACT_THRESHOLD),
getattr(compressor, "threshold_tokens", None) if compressor is not None else None,
)
return [{"type": "compaction", "compact_threshold": threshold}]


def is_native_compaction_rejection(error: Any) -> bool:
"""True when a provider error names the context_management field.

Used by the conversation loop's one-shot recovery: strip the field,
disable native compaction for the rest of the session, retry. Matching
is deliberately narrow — generic 4xx/5xx/timeouts must NOT permanently
downgrade native compaction, they take the normal retry path.
"""
text = str(error or "").lower()
return "context_management" in text or "compact_threshold" in text
7 changes: 7 additions & 0 deletions agent/transports/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ def build_kwargs(
replay_encrypted_reasoning = bool(
params.get("replay_encrypted_reasoning", True)
)
# Native server-side compaction (gpt-5.6 on direct OpenAI/Codex routes
# only). The caller resolves eligibility via
# agent.native_compaction.native_compaction_context_management();
# None means the field is never added to the request.
context_management = params.get("context_management")

# Resolve the issuing endpoint for this call. Stashed on the
# transport so normalize_response can stamp it onto reasoning
Expand Down Expand Up @@ -367,6 +372,8 @@ def build_kwargs(
kwargs["tools"] = response_tools
kwargs["tool_choice"] = "auto"
kwargs["parallel_tool_calls"] = True
if isinstance(context_management, list) and context_management:
kwargs["context_management"] = context_management

session_id = params.get("session_id")
# prompt_cache_key is content-addressed from the static prefix
Expand Down
1 change: 1 addition & 0 deletions agent/turn_retry_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class TurnRetryState:
# ── Format / payload recovery guards ─────────────────────────────────
thinking_sig_retry_attempted: bool = False
invalid_encrypted_content_retry_attempted: bool = False
native_compaction_reject_retry_attempted: bool = False
image_shrink_retry_attempted: bool = False
multimodal_tool_content_retry_attempted: bool = False
oauth_1m_beta_retry_attempted: bool = False
Expand Down
12 changes: 12 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,18 @@ compression:
# off = Hermes will not auto-trigger compaction; Codex may still compact natively
codex_app_server_auto: native

# Native OpenAI Responses server-side compaction (default: false). When true,
# gpt-5.6-family models on the DIRECT OpenAI API (api.openai.com) or a ChatGPT
# Codex subscription compact server-side: OpenAI prunes older context into an
# encrypted checkpoint that Hermes replays on later turns. No other provider,
# route, or model is affected. Hermes' local compression stays armed as the
# fallback and still handles every non-eligible session.
codex_responses_native: false

# Server-side compaction trigger in input tokens. Clamped below the local
# compression threshold at request time so the server compacts first.
codex_responses_compact_threshold: 200000

# Number of non-system messages to protect at the head of the transcript, in
# ADDITION to the system prompt (which is always implicitly protected).
# Head messages are NEVER summarized — they survive every compression
Expand Down
10 changes: 10 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,16 @@
# Hermes' compression threshold triggers
# thread/compact/start; off = never auto-trigger
# (codex may still compact natively).
"codex_responses_native": False, # Opt in to OpenAI's server-side compaction
# on the Responses API. Engages ONLY for
# gpt-5.6-family models on api.openai.com or
# the ChatGPT Codex backend; every other
# route/model is unaffected. Hermes' local
# compression stays armed as the fallback.
"codex_responses_compact_threshold": 200000, # Server-side compaction trigger
# (input tokens). Clamped below the local
# compression threshold at request time so
# the server compacts before Hermes does.
"in_place": True, # When True, compaction rewrites the message
# list and rebuilds the system prompt WITHOUT
# rotating the session id — the conversation
Expand Down
1 change: 1 addition & 0 deletions tests/agent/test_turn_retry_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"vertex_auth_retry_attempted",
"thinking_sig_retry_attempted",
"invalid_encrypted_content_retry_attempted",
"native_compaction_reject_retry_attempted",
"image_shrink_retry_attempted",
"multimodal_tool_content_retry_attempted",
"oauth_1m_beta_retry_attempted",
Expand Down
Loading
Loading