diff --git a/agent/agent_init.py b/agent/agent_init.py index d038c76183fd6..e0058a6a7cd6d 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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(). @@ -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 diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 158b1dce472e1..7e07aea731db2 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -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 @@ -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) ───────────────────────────────────── diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 8f64f64b76f3b..074fa942f0faa 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -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": @@ -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] = { @@ -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): @@ -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 diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 2ab35ec945e11..05981bca8610d 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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 diff --git a/agent/native_compaction.py b/agent/native_compaction.py new file mode 100644 index 0000000000000..5038aa78132b4 --- /dev/null +++ b/agent/native_compaction.py @@ -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 diff --git a/agent/transports/codex.py b/agent/transports/codex.py index a60e5291b5b5f..9c361e3d58cd1 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -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 @@ -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 diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py index d73fe5b6bfc8a..49790c6528c94 100644 --- a/agent/turn_retry_state.py +++ b/agent/turn_retry_state.py @@ -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 diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 1030f1b22d815..08343f15dd77d 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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 diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index b3073d9517a2d..8b5ef69c9022e 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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 diff --git a/tests/agent/test_turn_retry_state.py b/tests/agent/test_turn_retry_state.py index 28bdbb45772d9..a182f1779571a 100644 --- a/tests/agent/test_turn_retry_state.py +++ b/tests/agent/test_turn_retry_state.py @@ -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", diff --git a/tests/run_agent/test_native_compaction.py b/tests/run_agent/test_native_compaction.py new file mode 100644 index 0000000000000..55956758be959 --- /dev/null +++ b/tests/run_agent/test_native_compaction.py @@ -0,0 +1,407 @@ +"""Tests for native OpenAI Responses server-side compaction (gpt-5.6 only). + +Live behavior verified 2026-08-08 against api.openai.com: gpt-5.6 and +gpt-5.3-codex accept ``context_management`` and emit compaction items; +gpt-5.1/gpt-5.2 fail server-side (HTTP 500 / stream stall) with no +structured rejection — hence the hard model-family gate these tests pin. +""" + +from types import SimpleNamespace + +import pytest + +from agent.native_compaction import ( + DEFAULT_COMPACT_THRESHOLD, + is_direct_openai_route, + is_native_compaction_model, + is_native_compaction_rejection, + native_compaction_context_management, + resolve_compact_threshold, +) + + +def _agent( + model="gpt-5.6", + base_url="https://api.openai.com/v1", + enabled=True, + compression_enabled=True, + threshold=DEFAULT_COMPACT_THRESHOLD, + compressor=None, +): + return SimpleNamespace( + model=model, + base_url=base_url, + codex_responses_native_compaction=enabled, + compression_enabled=compression_enabled, + codex_responses_compact_threshold=threshold, + context_compressor=compressor, + ) + + +class TestModelGate: + def test_gpt56_family_eligible(self): + assert is_native_compaction_model("gpt-5.6") + assert is_native_compaction_model("gpt-5.6-mini") + assert is_native_compaction_model("GPT-5.6-2026-07-15") + + def test_other_models_ineligible(self): + # gpt-5.1/5.2 fail server-side on context_management (live-verified); + # gpt-5.3-codex works upstream but is outside the supported set. + for model in ("gpt-5.1", "gpt-5.2", "gpt-5.3-codex", "gpt-4o", "o3", ""): + assert not is_native_compaction_model(model) + assert not is_native_compaction_model(None) + + +class TestRouteGate: + def test_direct_openai_api(self): + assert is_direct_openai_route("https://api.openai.com/v1") + + def test_codex_backend_flag(self): + assert is_direct_openai_route( + "https://chatgpt.com/backend-api/codex", is_codex_backend=True + ) + + def test_everything_else_rejected(self): + for url in ( + "https://openrouter.ai/api/v1", + "https://api.x.ai/v1", + "https://models.github.ai/inference", + "http://localhost:1234/v1", + "https://api.openai.com.evil.com/v1", # suffix spoof + "", + None, + ): + assert not is_direct_openai_route(url), url + + +class TestRequestGate: + def test_eligible_route_gets_payload(self): + payload = native_compaction_context_management( + _agent(), is_codex_backend=False + ) + assert payload == [ + {"type": "compaction", "compact_threshold": DEFAULT_COMPACT_THRESHOLD} + ] + + def test_codex_backend_gets_payload(self): + payload = native_compaction_context_management( + _agent(base_url="https://chatgpt.com/backend-api/codex"), + is_codex_backend=True, + ) + assert payload is not None + + def test_disabled_by_default_config_value(self): + assert ( + native_compaction_context_management( + _agent(enabled=False), is_codex_backend=False + ) + is None + ) + + def test_compression_disabled_disables_native(self): + assert ( + native_compaction_context_management( + _agent(compression_enabled=False), is_codex_backend=False + ) + is None + ) + + def test_wrong_model_never_sends(self): + assert ( + native_compaction_context_management( + _agent(model="gpt-5.1"), is_codex_backend=False + ) + is None + ) + + def test_xai_and_github_surfaces_never_send(self): + agent = _agent() + assert ( + native_compaction_context_management( + agent, is_codex_backend=False, is_xai_responses=True + ) + is None + ) + assert ( + native_compaction_context_management( + agent, is_codex_backend=False, is_github_responses=True + ) + is None + ) + + def test_non_openai_route_never_sends(self): + assert ( + native_compaction_context_management( + _agent(base_url="https://openrouter.ai/api/v1"), + is_codex_backend=False, + ) + is None + ) + + def test_threshold_clamped_below_local_compressor(self): + compressor = SimpleNamespace(threshold_tokens=100_000) + payload = native_compaction_context_management( + _agent(compressor=compressor), is_codex_backend=False + ) + assert payload[0]["compact_threshold"] < 100_000 + + +class TestThresholdClamp: + def test_clamps_below_local_trigger(self): + assert resolve_compact_threshold(200_000, 100_000) == 100_000 - 8_192 + + def test_no_local_trigger_uses_configured(self): + assert resolve_compact_threshold(200_000, None) == 200_000 + + def test_garbage_configured_falls_back_to_default(self): + assert resolve_compact_threshold("garbage", None) == DEFAULT_COMPACT_THRESHOLD + assert resolve_compact_threshold(True, None) == DEFAULT_COMPACT_THRESHOLD + assert resolve_compact_threshold(-5, None) == DEFAULT_COMPACT_THRESHOLD + + def test_tiny_local_trigger_stays_positive(self): + assert resolve_compact_threshold(200_000, 4_000) >= 1_024 + + +class TestRejectionMatcher: + def test_structured_param_rejection_matches(self): + assert is_native_compaction_rejection( + "Error code: 400 - Unknown parameter: 'context_management'" + ) + assert is_native_compaction_rejection( + "invalid value for compact_threshold" + ) + + def test_generic_errors_do_not_match(self): + # Generic failures must take the normal retry path — a transient + # 500 or timeout must never permanently disable native compaction. + for err in ( + "An error occurred while processing your request", + "Broken pipe", + "Rate limit exceeded", + "", + None, + ): + assert not is_native_compaction_rejection(err) + + +class TestWirePlumbing: + """context_management flows through build_kwargs and both preflights.""" + + def test_transport_build_kwargs_includes_field(self): + from agent.transports.codex import ResponsesApiTransport + + transport = ResponsesApiTransport() + kwargs = transport.build_kwargs( + model="gpt-5.6", + messages=[{"role": "user", "content": "hi"}], + context_management=[{"type": "compaction", "compact_threshold": 4000}], + ) + assert kwargs["context_management"] == [ + {"type": "compaction", "compact_threshold": 4000} + ] + + def test_transport_build_kwargs_omits_field_when_none(self): + from agent.transports.codex import ResponsesApiTransport + + transport = ResponsesApiTransport() + kwargs = transport.build_kwargs( + model="gpt-5.6", + messages=[{"role": "user", "content": "hi"}], + context_management=None, + ) + assert "context_management" not in kwargs + + def test_preflight_preserves_field(self): + from agent.codex_responses_adapter import _preflight_codex_api_kwargs + + normalized = _preflight_codex_api_kwargs( + { + "model": "gpt-5.6", + "instructions": "You are a test.", + "input": [{"role": "user", "content": "hi"}], + "store": False, + "context_management": [ + {"type": "compaction", "compact_threshold": 4000} + ], + } + ) + assert normalized["context_management"] == [ + {"type": "compaction", "compact_threshold": 4000} + ] + + def test_preflight_accepts_replayed_compaction_input_item(self): + from agent.codex_responses_adapter import _preflight_codex_input_items + + items = _preflight_codex_input_items( + [ + {"type": "compaction", "encrypted_content": "opaque-blob"}, + {"role": "user", "content": "hi"}, + ] + ) + assert items[0] == {"type": "compaction", "encrypted_content": "opaque-blob"} + + def test_preflight_drops_empty_compaction_item(self): + from agent.codex_responses_adapter import _preflight_codex_input_items + + items = _preflight_codex_input_items( + [ + {"type": "compaction", "encrypted_content": ""}, + {"role": "user", "content": "hi"}, + ] + ) + assert all(item.get("type") != "compaction" for item in items) + + +class TestResponseCapture: + def test_compaction_output_item_lands_in_reasoning_sidecar(self): + from agent.codex_responses_adapter import _normalize_codex_response + + response = SimpleNamespace( + status="completed", + output=[ + SimpleNamespace( + type="compaction", + encrypted_content="blob123", + status="completed", + ), + SimpleNamespace( + type="message", + status="completed", + phase="final_answer", + content=[SimpleNamespace(type="output_text", text="OK")], + id="msg_1", + ), + ], + ) + msg, finish_reason = _normalize_codex_response( + response, issuer_kind="other:https://api.openai.com/v1" + ) + assert finish_reason == "stop" + compaction_items = [ + item + for item in (msg.codex_reasoning_items or []) + if item.get("type") == "compaction" + ] + assert len(compaction_items) == 1 + assert compaction_items[0]["encrypted_content"] == "blob123" + assert compaction_items[0]["_issuer_kind"] == "other:https://api.openai.com/v1" + + def test_compaction_item_replayed_on_next_turn(self): + from agent.codex_responses_adapter import _chat_messages_to_responses_input + + items = _chat_messages_to_responses_input( + [ + { + "role": "assistant", + "content": "OK", + "codex_reasoning_items": [ + { + "type": "compaction", + "encrypted_content": "blob123", + "_issuer_kind": "codex_backend", + } + ], + }, + {"role": "user", "content": "next"}, + ], + current_issuer_kind="codex_backend", + ) + replayed = [item for item in items if item.get("type") == "compaction"] + assert len(replayed) == 1 + assert replayed[0]["encrypted_content"] == "blob123" + # Internal stamp must not go over the wire. + assert "_issuer_kind" not in replayed[0] + + def test_foreign_issuer_compaction_item_dropped(self): + from agent.codex_responses_adapter import _chat_messages_to_responses_input + + items = _chat_messages_to_responses_input( + [ + { + "role": "assistant", + "content": "OK", + "codex_reasoning_items": [ + { + "type": "compaction", + "encrypted_content": "blob123", + "_issuer_kind": "codex_backend", + } + ], + }, + {"role": "user", "content": "next"}, + ], + current_issuer_kind="xai_responses", + ) + assert all(item.get("type") != "compaction" for item in items) + + +class TestAgentInitConfig: + def test_defaults_off_and_threshold(self, monkeypatch): + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://api.openai.com/v1", + api_mode="codex_responses", + model="gpt-5.6", + provider="openai-api", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=[], + ) + assert agent.codex_responses_native_compaction is False + assert agent.codex_responses_compact_threshold == 200_000 + + def test_kwargs_have_no_context_management_by_default(self): + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://api.openai.com/v1", + api_mode="codex_responses", + model="gpt-5.6", + provider="openai-api", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=[], + ) + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert "context_management" not in kwargs + + def test_kwargs_include_field_when_enabled_on_eligible_route(self): + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://api.openai.com/v1", + api_mode="codex_responses", + model="gpt-5.6", + provider="openai-api", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=[], + ) + agent.codex_responses_native_compaction = True + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert isinstance(kwargs.get("context_management"), list) + + def test_kwargs_omit_field_for_ineligible_model_even_when_enabled(self): + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://api.openai.com/v1", + api_mode="codex_responses", + model="gpt-5.1", + provider="openai-api", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=[], + ) + agent.codex_responses_native_compaction = True + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + assert "context_management" not in kwargs diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index 0b71f4eb6d1f6..4284ebe3b0291 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -91,6 +91,8 @@ compression: codex_gpt55_autoraise: true # gpt-5.5 on Codex OAuth: raise trigger to 85% (default: true) codex_gpt55_autoraise_notice: true # Show the one-time autoraise notice (default: true) codex_app_server_auto: native # native|hermes|off for Codex app-server thread compaction + codex_responses_native: false # gpt-5.6 on direct OpenAI/Codex: server-side compaction (opt-in) + codex_responses_compact_threshold: 200000 # Server-side compaction trigger (input tokens) in_place: true # Compact on the same session id, no rotation (default: true) # Summarization model/provider configured under auxiliary: @@ -115,6 +117,8 @@ auxiliary: | `codex_gpt55_autoraise` | `true` | bool | Raise the trigger to 85% for gpt-5.5 on the ChatGPT Codex OAuth route (see below). Set `false` to keep the global `threshold` | | `codex_gpt55_autoraise_notice` | `true` | bool | Show the one-time Codex gpt-5.5 autoraise notice. Set `false` to keep the 85% autoraise but suppress the banner | | `codex_app_server_auto` | `native` | `native`, `hermes`, `off` | Thread-compaction mode for Codex app-server sessions (see below) | +| `codex_responses_native` | `false` | bool | Opt in to OpenAI's server-side compaction on the Responses API. Engages only for gpt-5.6-family models on the direct OpenAI API or a ChatGPT Codex subscription (see below) | +| `codex_responses_compact_threshold` | `200000` | ≥1 tokens | Server-side compaction trigger in input tokens. Clamped below the local compression threshold at request time so the server compacts first | | `in_place` | `true` | bool | Compact on the same session id instead of rotating to a new one (see below) | ### In-place compaction (single stable session id) @@ -208,6 +212,35 @@ Hermes' local transcript is never rewritten on this runtime — state.db records the compaction boundary while the visible transcript stays intact. All other routes (including Codex OAuth chat sessions) keep Hermes' summary compressor. +### Native Responses compaction (gpt-5.6 on direct OpenAI / Codex subscription) + +OpenAI's Responses API supports server-side compaction: when a request includes +`context_management: [{type: "compaction", compact_threshold: N}]` and the +rendered input crosses N tokens, the server prunes older context into an opaque +encrypted `compaction` output item. Hermes captures that item into the +assistant message's existing replay sidecar and sends it back on subsequent +turns, standing in for the pruned history — long-horizon recall without a +client-side summary pass, and ZDR-friendly (`store: false`, no +`previous_response_id`). + +Opt in with `compression.codex_responses_native: true`. The gate is deliberately +narrow, re-checked on every request: + +- **Models:** the gpt-5.6 family only. Other models fail server-side when the + field is present (gpt-5.1/5.2 return HTTP 500 or stall the stream — there is + no structured rejection to downgrade on, verified live Aug 2026). +- **Routes:** `api.openai.com` (OpenAI API key) or the ChatGPT Codex backend + (Codex subscription OAuth) only. xAI, GitHub/Copilot, OpenRouter, relays, and + local servers never see the field. + +Everything else about compression is unchanged: the local compressor stays +armed as the fallback owner (the native threshold is clamped ~8K tokens below +the local trigger so the server compacts first), and a structured provider +rejection of the field disables native compaction for the session and retries +the request without it. Switching the session to a non-eligible model or route +simply stops the field from being sent — captured checkpoints are dropped from +replay by the existing cross-issuer guard when the endpoint changes. + ### Computed Values (for a 200K context model at defaults) ```