{"repo":"NousResearch/hermes-agent","public_id":"33130","display_label":"Codex compaction API integration for context compression","summary":"These members concern routing context compaction through the OpenAI Codex Compaction API rather than internal compression mechanisms. Work spans agent and app-server layers to add Codex-native compaction paths.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60114,"merged_at":"2026-07-07T09:39:54+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-07T09:39:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":33130,"pr_number":60114,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-07T09:39:54+00:00"},{"issue_number":36801,"pr_number":60114,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-07T09:39:54+00:00"}],"members":[{"kind":"issue","number":33130,"title":"[Feature]: Use OpenAI Codex Compaction API instead of internal Compress for context compaction.","url":null,"state":"closed"},{"kind":"pr","number":54633,"title":"feat(agent): add Codex-native compaction paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"87b65e24a","number":0},{"kind":"pr","value":"#60114","number":60114},{"kind":"pr","value":"#36801","number":36801}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60114,"title":"feat(compression): route Codex app-server compaction through thread/compact/start (#36801)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","agent/codex_runtime.py","agent/conversation_compression.py","agent/transports/codex_app_server_session.py","agent/turn_context.py","cli-config.yaml.example","gateway/run.py","hermes_cli/config.py","scripts/release.py","tests/agent/transports/test_codex_app_server_session.py","tests/gateway/test_agent_cache.py","tests/hermes_cli/test_config.py","tests/run_agent/test_codex_app_server_compaction.py","tests/run_agent/test_codex_app_server_integration.py","website/docs/developer-guide/context-compression-and-caching.md"],"fix_hunks":[{"file":"agent/agent_init.py","hunks":["@@ -1570,6 +1570,16 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:\n     compression_in_place = is_truthy_value(\n         _compression_cfg.get(\"in_place\"), default=False\n     )\n+    codex_app_server_auto_compaction = str(\n+        _compression_cfg.get(\"codex_app_server_auto\", \"native\") or \"native\"\n+    ).lower()\n+    if codex_app_server_auto_compaction not in {\"native\", \"hermes\", \"off\"}:\n+        _ra().logger.warning(\n+            \"Invalid compression.codex_app_server_auto=%r; using 'native'. \"\n+            \"Valid values are: native, hermes, off.\",\n+            codex_app_server_auto_compaction,\n+        )\n+        codex_app_server_auto_compaction = \"native\"\n \n     # Read optional explicit context_length override for the auxiliary\n     # compression model. Custom endpoints often cannot report this via","@@ -1824,6 +1834,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:\n             pass\n     agent.compression_enabled = compression_enabled\n     agent.compression_in_place = compression_in_place\n+    agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction\n \n     # Reject models whose context window is below the minimum required\n     # for reliable tool-calling workflows (64K tokens)."]},{"file":"agent/codex_runtime.py","hunks":["@@ -228,6 +228,76 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:\n     }\n \n \n+def _record_codex_app_server_compaction(\n+    agent,\n+    turn,\n+    *,\n+    approx_tokens: int | None = None,\n+    force: bool = False,\n+) -> bool:\n+    \"\"\"Record a Codex-native context compaction boundary in Hermes state.\n+\n+    The app-server owns the compacted thread context, so Hermes should not\n+    rewrite local transcript rows here; state.db records the boundary via the\n+    session event/usage counters while preserving the visible transcript.\n+    \"\"\"\n+    if not force and not getattr(turn, \"compacted\", False):\n+        return False\n+\n+    thread_id = getattr(turn, \"thread_id\", None) or \"\"\n+    turn_id = getattr(turn, \"turn_id\", None) or \"\"\n+    logger.info(\n+        \"codex app-server compaction observed: session=%s thread=%s turn=%s force=%s\",\n+        getattr(agent, \"session_id\", None) or \"none\",\n+        thread_id,\n+        turn_id,\n+        force,\n+    )\n+    if not force:\n+        try:\n+            from agent.conversation_compression import COMPACTION_STATUS\n+\n+            agent._emit_status(COMPACTION_STATUS)\n+        except Exception:\n+            pass\n+\n+    compressor = getattr(agent, \"context_compressor\", None)\n+    if compressor is not None:\n+        compressor.compression_count = getattr(\n+            compressor, \"compression_count\", 0\n+        ) + 1\n+        compressor.last_compression_rough_tokens = [redacted:secret-assignment] or 0\n+        if not getattr(turn, \"token_usage_last\", None):\n+            compressor.last_prompt_tokens = -1\n+            compressor.last_completion_tokens = 0\n+            compressor.awaiting_real_usage_after_compression = True\n+\n+    agent._last_compaction_in_place = False\n+    try:\n+        if getattr(agent, \"event_callback\", None):\n+            agent.event_callback(\n+                \"session:compress\",\n+                {\n+                    \"platform\": getattr(agent, \"platform\", None) or \"\",\n+                    \"session_id\": getattr(agent, \"session_id\", None) or \"\",\n+                    \"old_session_id\": \"\",\n+                    \"in_place\": False,\n+                    \"compression_count\": getattr(\n+                        compressor, \"compression_count\", 0\n+                    )\n+                    if compressor is not None\n+                    else 0,\n+                    \"runtime\": \"codex_app_server\",\n+                    \"thread_id\": thread_id,\n+                    \"turn_id\": turn_id,\n+                },\n+            )\n+    except Exception:\n+        logger.debug(\"event_callback error on codex session:compress\", exc_info=True)\n+\n+    return True\n+\n+\n def run_codex_app_server_turn(\n     agent,\n     *,","@@ -393,6 +463,7 @@ def _on_codex_event(note: dict) -> None:\n     agent._iters_since_skill = (\n         getattr(agent, \"_iters_since_skill\", 0) + turn.tool_iterations\n     )\n+    _record_codex_app_server_compaction(agent, turn)\n     usage_result = _record_codex_app_server_usage(agent, turn)\n     api_calls = 1\n "]},{"file":"agent/conversation_compression.py","hunks":["@@ -465,6 +465,21 @@ def compress_context(\n         prompt — the session is NOT rotated.  Callers should detect the\n         no-op via ``len(returned) == len(input)`` and stop the retry loop.\n     \"\"\"\n+    # Codex app-server sessions: the codex agent owns the real thread context;\n+    # Hermes' summarizer would only rewrite a local mirror without shrinking\n+    # the actual thread (#36801). Route compaction to the app server's own\n+    # thread/compact mechanism. Behavior is controlled by\n+    # ``compression.codex_app_server_auto`` (native|hermes|off).\n+    if getattr(agent, \"api_mode\", None) == \"codex_app_server\":\n+        return _compress_context_via_codex_app_server(\n+            agent,\n+            messages,\n+            system_message,\n+            approx_tokens=[redacted:secret-assignment]\n+            task_id=task_id,\n+            force=force,\n+        )\n+\n     # Lazy feasibility check — run the auxiliary-provider probe + context\n     # length lookup just-in-time on the first compression attempt instead of\n     # at AIAgent.__init__. Saves ~400ms cold off every short session that","@@ -971,6 +986,122 @@ def _release_lock() -> None:\n         _release_lock()\n \n \n+def _compress_context_via_codex_app_server(\n+    agent: Any,\n+    messages: list,\n+    system_message: Optional[str],\n+    *,\n+    approx_tokens: [redacted:secret-assignment] = None,\n+    task_id: str = \"default\",\n+    force: bool = False,\n+) -> Tuple[list, str]:\n+    \"\"\"Route compaction to Codex app-server for Codex-owned threads.\n+\n+    Hermes' normal compressor rewrites the local OpenAI-style transcript.\n+    That does not shrink the actual Codex app-server thread context. For this\n+    runtime, ask Codex to compact its own thread and keep Hermes' transcript\n+    unchanged.\n+    \"\"\"\n+    auto_mode = str(\n+        getattr(agent, \"codex_app_server_auto_compaction\", \"native\") or \"native\"\n+    ).lower()\n+    if auto_mode not in {\"native\", \"hermes\", \"off\"}:\n+        auto_mode = \"native\"\n+    if not force and auto_mode != \"hermes\":\n+        logger.info(\n+            \"codex app-server compaction skipped: mode=%s force=false \"\n+            \"(session=%s messages=%d tokens=~%s)\",\n+            auto_mode,\n+            getattr(agent, \"session_id\", None) or \"none\",\n+            len(messages),\n+            f\"{approx_tokens:,}\" if approx_tokens else \"unknown\",\n+        )\n+        existing_prompt = getattr(agent, \"_cached_system_prompt\", None)\n+        if not existing_prompt:\n+            existing_prompt = agent._build_system_prompt(system_message)\n+        return messages, existing_prompt\n+\n+    codex_session = getattr(agent, \"_codex_session\", None)\n+    if codex_session is None:\n+        logger.info(\n+            \"codex app-server compaction skipped: no active codex thread \"\n+            \"(session=%s messages=%d tokens=~%s)\",\n+            getattr(agent, \"session_id\", None) or \"none\",\n+            len(messages),\n+            f\"{approx_tokens:,}\" if approx_tokens else \"unknown\",\n+        )\n+        existing_prompt = getattr(agent, \"_cached_system_prompt\", None)\n+        if not existing_prompt:\n+            existing_prompt = agent._build_system_prompt(system_message)\n+        return messages, existing_prompt\n+\n+    logger.info(\n+        \"codex app-server compaction started: session=%s messages=%d tokens=~%s\",\n+        getattr(agent, \"session_id\", None) or \"none\",\n+        len(messages),\n+        f\"{approx_tokens:,}\" if approx_tokens else \"unknown\",\n+    )\n+    try:\n+        agent._emit_status(COMPACTION_STATUS)\n+    except Exception:\n+        pass\n+\n+    result = codex_session.compact_thread()\n+    if getattr(result, \"should_retire\", False):\n+        try:\n+            codex_session.close()\n+        except Exception:\n+            pass\n+        agent._codex_session = None\n+\n+    if getattr(result, \"error\", None):\n+        try:\n+            agent._emit_warning(\n+                f\"⚠ Codex app-server compaction failed: {result.error}\"\n+            )\n+        except Exception:\n+            pass\n+        existing_prompt = getattr(agent, \"_cached_system_prompt\", None)\n+        if not existing_prompt:\n+            existing_prompt = agent._build_system_prompt(system_message)\n+        return messages, existing_prompt\n+\n+    try:\n+        from agent.codex_runtime import (\n+            _record_codex_app_server_compaction,\n+            _record_codex_app_server_usage,\n+        )\n+\n+        _record_codex_app_server_compaction(\n+            agent,\n+            result,\n+            approx_tokens=[redacted:secret-assignment]\n+            force=True,\n+        )\n+        if getattr(result, \"token_usage_last\", None):\n+            _record_codex_app_server_usage(agent, result)\n+    except Exception:\n+        logger.debug(\"codex compaction bookkeeping failed\", exc_info=True)\n+\n+    try:\n+        from tools.file_tools import reset_file_dedup\n+\n+        reset_file_dedup(task_id)\n+    except Exception:\n+        pass\n+\n+    logger.info(\n+        \"codex app-server compaction done: session=%s thread=%s turn=%s\",\n+        getattr(agent, \"session_id\", None) or \"none\",\n+        getattr(result, \"thread_id\", None) or \"\",\n+        getattr(result, \"turn_id\", None) or \"\",\n+    )\n+    existing_prompt = getattr(agent, \"_cached_system_prompt\", None)\n+    if not existing_prompt:\n+        existing_prompt = agent._build_system_prompt(system_message)\n+    return messages, existing_prompt\n+\n+\n def try_shrink_image_parts_in_messages(\n     api_messages: list,\n     *,"]},{"file":"agent/transports/codex_app_server_session.py","hunks":["@@ -75,6 +75,7 @@ class TurnResult:\n     token_usage_last: [redacted:secret-assignment] Any]] = None\n     token_usage_total: [redacted:secret-assignment] Any]] = None\n     model_context_window: Optional[int] = None\n+    compacted: bool = False\n     # Hint to the caller that the underlying codex subprocess is likely\n     # wedged (turn-level timeout fired, post-tool watchdog tripped, or\n     # token-refresh failure killed the child). The caller should retire","@@ -505,6 +506,7 @@ def run_turn(\n                     if pending is None:\n                         break\n                     _apply_token_usage_notification(result, pending)\n+                    _apply_compaction_notification(result, pending)\n                     self._track_pending_file_change(pending)\n                     proj = projector.project(pending)\n                     if proj.messages:","@@ -541,6 +543,7 @@ def run_turn(\n                     logger.debug(\"on_event callback raised\", exc_info=True)\n \n             _apply_token_usage_notification(result, note)\n+            _apply_compaction_notification(result, note)\n \n             # Track in-progress fileChange items so the approval bridge\n             # can surface a real change summary when codex requests","@@ -632,6 +635,154 @@ def run_turn(\n \n         return result\n \n+    def compact_thread(\n+        self,\n+        *,\n+        turn_timeout: float = 600.0,\n+        notification_poll_timeout: float = 0.25,\n+    ) -> TurnResult:\n+        \"\"\"Trigger Codex-native history compaction for the current thread.\n+\n+        `thread/compact/start` returns immediately; the actual compaction\n+        progress streams through the same turn/item notifications as a normal\n+        turn. We wait for the matching `turn/completed` so callers can treat a\n+        successful return as a completed compaction boundary.\n+        \"\"\"\n+        result = TurnResult()\n+        try:\n+            self.ensure_started()\n+        except (CodexAppServerError, TimeoutError) as exc:\n+            result.error = self._format_error_with_stderr(\n+                \"codex app-server startup failed\", exc\n+            )\n+            result.should_retire = True\n+            return result\n+\n+        assert self._client is not None and self._thread_id is not None\n+        result.thread_id = self._thread_id\n+        self._interrupt_event.clear()\n+        projector = CodexEventProjector()\n+\n+        try:\n+            self._client.request(\n+                \"thread/compact/start\",\n+                {\"threadId\": self._thread_id},\n+                timeout=10,\n+            )\n+        except CodexAppServerError as exc:\n+            stderr_blob = \"\\n\".join(self._client.stderr_tail(40))\n+            hint = _classify_oauth_failure(exc.message, stderr_blob)\n+            if hint is not None:\n+                result.error = hint\n+                result.should_retire = True\n+            else:\n+                result.error = self._format_error_with_stderr(\n+                    \"thread/compact/start failed\", exc\n+                )\n+            return result\n+        except TimeoutError as exc:\n+            stderr_blob = \"\\n\".join(self._client.stderr_tail(40))\n+            hint = _classify_oauth_failure(stderr_blob)\n+            result.error = hint or self._format_error_with_stderr(\n+                \"thread/compact/start timed out\", exc\n+            )\n+            result.should_retire = True\n+            return result\n+\n+        deadline = time.monotonic() + turn_timeout\n+        turn_complete = False\n+\n+        while time.monotonic() < deadline and not turn_complete:\n+            if self._interrupt_event.is_set():\n+                self._issue_interrupt(result.turn_id)\n+                result.interrupted = True\n+                break\n+\n+            if not self._client.is_alive():\n+                stderr_blob = \"\\n\".join(self._client.stderr_tail(60))\n+                hint = _classify_oauth_failure(stderr_blob)\n+                if hint is not None:\n+                    result.error = hint\n+                else:\n+                    result.error = self._format_error_with_stderr(\n+                        \"codex app-server subprocess exited unexpectedly\",\n+                        tail_lines=20,\n+                    )\n+                result.should_retire = True\n+                break\n+\n+            sreq = self._client.take_server_request(timeout=0)\n+            if sreq is not None:\n+                self._handle_server_request(sreq)\n+                continue\n+\n+            note = self._client.take_notification(\n+                timeout=notification_poll_timeout\n+            )\n+            if note is None:\n+                continue\n+\n+            method = note.get(\"method\", \"\")\n+            if self._on_event is not None:\n+                try:\n+                    self._on_event(note)\n+                except Exception:  # pragma: no cover - display callback\n+                    logger.debug(\"on_event callback raised\", exc_info=True)\n+\n+            _apply_token_usage_notification(result, note)\n+            _apply_compaction_notification(result, note)\n+            self._track_pending_file_change(note)\n+\n+            projection = projector.project(note)\n+            if projection.messages:\n+                result.projected_messages.extend(projection.messages)\n+            if projection.is_tool_iteration:\n+                result.tool_iterations += 1\n+            if projection.final_text is not None:\n+                result.final_text = projection.final_text\n+                if _has_turn_aborted_marker(projection.final_text):\n+                    turn_complete = True\n+                    result.interrupted = True\n+                    result.error = (\n+                        result.error or \"codex reported turn_aborted\"\n+                    )\n+\n+            if method == \"turn/started\":\n+                turn_obj = (note.get(\"params\") or {}).get(\"turn\") or {}\n+                result.turn_id = turn_obj.get(\"id\") or result.turn_id\n+            elif method == \"turn/completed\":\n+                turn_complete = True\n+                turn_obj = (note.get(\"params\") or {}).get(\"turn\") or {}\n+                result.turn_id = turn_obj.get(\"id\") or result.turn_id\n+                turn_status = turn_obj.get(\"status\")\n+                if turn_status and turn_status not in {\"completed\", \"interrupted\"}:\n+                    err_obj = turn_obj.get(\"error\")\n+                    if err_obj:\n+                        err_msg = _format_responses_error(err_obj, str(turn_status))\n+                        stderr_blob = \"\\n\".join(\n+                            self._client.stderr_tail(40)\n+                        )\n+                        hint = _classify_oauth_failure(err_msg, stderr_blob)\n+                        if hint is not None:\n+                            result.error = hint\n+                            result.should_retire = True\n+                        else:\n+                            result.error = self._format_error_with_stderr(\n+                                f\"compact turn ended status={turn_status}\",\n+                                err_msg,\n+                            )\n+\n+        if not turn_complete and not result.interrupted:\n+            self._issue_interrupt(result.turn_id)\n+            result.interrupted = True\n+            if not result.error:\n+                result.error = self._format_error_with_stderr(\n+                    f\"compact turn timed out after {turn_timeout}s\"\n+                )\n+            result.should_retire = True\n+\n+        return result\n+\n     # ---------- internals ----------\n \n     def _issue_interrupt(self, turn_id: Optional[str]) -> None:","@@ -845,6 +996,38 @@ def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:\n         result.model_context_window = window\n \n \n+def _apply_compaction_notification(result: TurnResult, note: dict) -> None:\n+    \"\"\"Capture Codex-native context compaction boundaries.\n+\n+    Recent app-server builds expose compaction as a ContextCompaction item.\n+    Older builds also emit the deprecated thread/compacted notification. Both\n+    mean the underlying Codex thread history has been compacted.\n+    \"\"\"\n+    if not isinstance(note, dict):\n+        return\n+    method = note.get(\"method\") or \"\"\n+    params = note.get(\"params\") or {}\n+    if not isinstance(params, dict):\n+        return\n+\n+    if method == \"thread/compacted\":\n+        result.compacted = True\n+        result.thread_id = params.get(\"threadId\") or result.thread_id\n+        result.turn_id = params.get(\"turnId\") or result.turn_id\n+        return\n+\n+    if method not in {\"item/started\", \"item/completed\"}:\n+        return\n+\n+    item = params.get(\"item\") or {}\n+    if not isinstance(item, dict) or item.get(\"type\") != \"contextCompaction\":\n+        return\n+\n+    result.compacted = True\n+    result.thread_id = params.get(\"threadId\") or result.thread_id\n+    result.turn_id = params.get(\"turnId\") or result.turn_id\n+\n+\n def _approval_choice_to_codex_decision(choice: str) -> str:\n     \"\"\"Map Hermes approval choices onto codex's CommandExecutionApprovalDecision\n     / FileChangeApprovalDecision wire values."]},{"file":"agent/turn_context.py","hunks":["@@ -369,6 +369,20 @@ def build_turn_context(\n             lambda _tokens: [redacted:secret-assignment]\n         )\n         _preflight_deferred = _defer_preflight(_preflight_tokens)\n+        # Codex app-server threads are compacted by the codex agent itself;\n+        # Hermes only initiates compaction in \"hermes\" mode (#36801).\n+        _codex_native_auto = (\n+            getattr(agent, \"api_mode\", None) == \"codex_app_server\"\n+            and str(\n+                getattr(\n+                    agent,\n+                    \"codex_app_server_auto_compaction\",\n+                    \"native\",\n+                )\n+                or \"native\"\n+            ).lower()\n+            in {\"native\", \"off\"}\n+        )\n \n         if not _preflight_deferred:\n             _last = _compressor.last_prompt_tokens","@@ -397,6 +411,12 @@ def build_turn_context(\n                 int(_compression_cooldown.get(\"remaining_seconds\", 0.0)),\n                 agent.session_id or \"none\",\n             )\n+        elif _codex_native_auto:\n+            logger.info(\n+                \"Skipping Hermes preflight compression for codex app-server \"\n+                \"(mode=%s); Hermes will not start thread compaction here.\",\n+                getattr(agent, \"codex_app_server_auto_compaction\", \"native\"),\n+            )\n         elif _compressor.should_compress(_preflight_tokens):\n             logger.info(\n                 \"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)\","]},{"file":"cli-config.yaml.example","hunks":["@@ -410,7 +410,11 @@ compression:\n   # Trigger compression at this % of model's context limit (default: 0.50 = 50%)\n   # Lower values = more aggressive compression, higher values = compress later\n   threshold: 0.50\n-  \n+\n+  # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85%\n+  # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold.\n+  codex_gpt55_autoraise: true\n+\n   # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%)\n   # e.g. 20% of 50% threshold = 10% of total context kept as recent messages.\n   # Summary output is separately capped at 12K tokens (Gemini output limit).","@@ -422,6 +426,14 @@ compression:\n   # compression of older turns.\n   protect_last_n: 20\n \n+  # Codex app-server (codex CLI runtime) thread-compaction mode. The codex\n+  # agent owns the real thread context on this runtime, so Hermes' summarizer\n+  # cannot shrink it — compaction goes through the app server instead.\n+  #   native = let Codex decide when to compact its own thread (default)\n+  #   hermes = let Hermes threshold trigger Codex thread/compact/start\n+  #   off    = Hermes will not auto-trigger compaction; Codex may still compact natively\n+  codex_app_server_auto: native\n+\n   # Number of non-system messages to protect at the head of the transcript, in\n   # ADDITION to the system prompt (which is always implicitly protected).\n   # Head messages are NEVER summarized — they survive every compression"]},{"file":"gateway/run.py","hunks":["@@ -15321,6 +15321,8 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n         (\"model\", \"max_tokens\"),\n         (\"compression\", \"enabled\"),\n         (\"compression\", \"threshold\"),\n+        (\"compression\", \"codex_gpt55_autoraise\"),\n+        (\"compression\", \"codex_app_server_auto\"),\n         (\"compression\", \"target_ratio\"),\n         (\"compression\", \"protect_last_n\"),\n         (\"agent\", \"disabled_toolsets\"),"]},{"file":"hermes_cli/config.py","hunks":["@@ -1377,6 +1377,14 @@ def _ensure_hermes_home_managed(home: Path):\n                                       # autoraise banner. Set False to keep the\n                                       # 85% threshold autoraise but suppress the\n                                       # user-facing notice in CLI/gateway output.\n+        \"codex_app_server_auto\": \"native\",  # Codex app-server (codex CLI runtime) thread\n+                                      # compaction mode. The codex agent owns the real\n+                                      # thread context, so Hermes' summarizer cannot\n+                                      # shrink it (#36801). native = codex decides when\n+                                      # to compact its own thread (default); hermes =\n+                                      # Hermes' compression threshold triggers\n+                                      # thread/compact/start; off = never auto-trigger\n+                                      # (codex may still compact natively).\n         \"in_place\": True,             # When True, compaction rewrites the message\n                                       # list and rebuilds the system prompt WITHOUT\n                                       # rotating the session id — the conversation"]},{"file":"scripts/release.py","hunks":["@@ -162,6 +162,7 @@\n     \"65363919+coygeek@users.noreply.github.com\": \"coygeek\",  # PR #37951 salvage (fail closed when provider env blocklist import fails; #37950)\n     \"5261694+djstunami@users.noreply.github.com\": \"djstunami\",  # PR #5316 salvage / co-author (suppress transient check_fn flakes so subagents keep file/terminal tools; #21658 / #5304)\n     \"[redacted:email]\": \"jmmaloney4\",  # PR #25206 salvage (re-select credential pool on primary runtime restore; #25205)\n+    \"hmirin@users.noreply.github.com\": \"hmirin\",\n     \"[redacted:email]\": \"dalenguyen\",  # PR #53678 salvage (strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env; #23473)\n     \"[redacted:email]\": \"HexLab98\",  # PR #53863 salvage (env-only proxy policy for auxiliary OpenAI clients on macOS; #53702)\n     \"[redacted:email]\": \"Blaryxoff\",  # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396)"]},{"file":"tests/agent/transports/test_codex_app_server_session.py","hunks":["@@ -445,6 +445,107 @@ def test_failed_turn_records_error_from_turn_completed(self):\n         r = s.run_turn(\"x\", turn_timeout=1.0)\n         assert r.error and \"model error\" in r.error\n \n+    def test_run_turn_records_native_compaction_item(self):\n+        client = FakeClient()\n+        client.queue_notification(\n+            \"item/completed\",\n+            threadId=\"thread-fake-001\",\n+            turnId=\"turn-fake-001\",\n+            item={\"type\": \"contextCompaction\", \"id\": \"compact-item-1\"},\n+        )\n+        client.queue_notification(\n+            \"turn/completed\", threadId=\"thread-fake-001\",\n+            turn={\"id\": \"turn-fake-001\", \"status\": \"completed\", \"error\": None},\n+        )\n+\n+        r = make_session(client).run_turn(\"x\", turn_timeout=1.0)\n+\n+        assert r.compacted is True\n+        assert r.thread_id == \"thread-fake-001\"\n+        assert r.turn_id == \"turn-fake-001\"\n+\n+    def test_run_turn_records_deprecated_thread_compacted_notification(self):\n+        client = FakeClient()\n+        client.queue_notification(\n+            \"thread/compacted\",\n+            threadId=\"thread-fake-001\",\n+            turnId=\"turn-fake-001\",\n+        )\n+        client.queue_notification(\n+            \"turn/completed\", threadId=\"thread-fake-001\",\n+            turn={\"id\": \"turn-fake-001\", \"status\": \"completed\", \"error\": None},\n+        )\n+\n+        r = make_session(client).run_turn(\"x\", turn_timeout=1.0)\n+\n+        assert r.compacted is True\n+\n+\n+class TestCompactThread:\n+    def test_compact_thread_sends_rpc_and_waits_for_completion(self):\n+        client = FakeClient()\n+        client.queue_notification(\n+            \"turn/started\",\n+            threadId=\"thread-fake-001\",\n+            turn={\"id\": \"compact-turn-1\"},\n+        )\n+        client.queue_notification(\n+            \"item/completed\",\n+            threadId=\"thread-fake-001\",\n+            turnId=\"compact-turn-1\",\n+            item={\"type\": \"contextCompaction\", \"id\": \"compact-item-1\"},\n+        )\n+        client.queue_notification(\n+            \"item/completed\",\n+            threadId=\"thread-fake-001\",\n+            turnId=\"compact-turn-1\",\n+            item={\"type\": \"agentMessage\", \"id\": \"m1\", \"text\": \"compacted\"},\n+        )\n+        client.queue_notification(\n+            \"thread/tokenUsage/updated\",\n+            threadId=\"thread-fake-001\",\n+            turnId=\"compact-turn-1\",\n+            tokenUsage={\n+                \"last\": {\"inputTokens\": 10, \"outputTokens\": 2, \"totalTokens\": 12},\n+                \"total\": {\"inputTokens\": 100, \"outputTokens\": 20, \"totalTokens\": 120},\n+                \"modelContextWindow\": 200000,\n+            },\n+        )\n+        client.queue_notification(\n+            \"turn/completed\",\n+            threadId=\"thread-fake-001\",\n+            turn={\"id\": \"compact-turn-1\", \"status\": \"completed\", \"error\": None},\n+        )\n+\n+        r = make_session(client).compact_thread(turn_timeout=2.0)\n+\n+        assert (\"thread/compact/start\", {\"threadId\": \"thread-fake-001\"}) in client.requests\n+        assert r.error is None\n+        assert r.thread_id == \"thread-fake-001\"\n+        assert r.turn_id == \"compact-turn-1\"\n+        assert r.compacted is True\n+        assert r.final_text == \"compacted\"\n+        assert r.token_usage_last[\"totalTokens\"] == 12\n+        assert r.model_context_window == 200000\n+\n+    def test_compact_thread_failure_returns_error(self):\n+        client = FakeClient()\n+        from agent.transports.codex_app_server import CodexAppServerError\n+\n+        def boom(method, params):\n+            if method == \"thread/compact/start\":\n+                raise CodexAppServerError(code=-32603, message=\"compact unavailable\")\n+            if method == \"thread/start\":\n+                return {\"thread\": {\"id\": \"thread-fake-001\"}}\n+            return {}\n+\n+        client._request_handler = boom\n+        r = make_session(client).compact_thread(turn_timeout=2.0)\n+\n+        assert r.error is not None\n+        assert \"compact unavailable\" in r.error\n+        assert r.should_retire is False\n+\n \n # ---- approval bridge ----\n "]},{"file":"tests/gateway/test_agent_cache.py","hunks":["@@ -228,16 +228,20 @@ def test_reads_compression_subkeys(self):\n                 \"compression\": {\n                     \"enabled\": False,\n                     \"threshold\": 0.6,\n+                    \"codex_gpt55_autoraise\": False,\n                     \"target_ratio\": 0.3,\n                     \"protect_last_n\": 25,\n+                    \"codex_app_server_auto\": \"hermes\",\n                     \"some_other_key\": \"ignored\",\n                 }\n             }\n         )\n         assert out[\"compression.enabled\"] is False\n         assert out[\"compression.threshold\"] == 0.6\n+        assert out[\"compression.codex_gpt55_autoraise\"] is False\n         assert out[\"compression.target_ratio\"] == 0.3\n         assert out[\"compression.protect_last_n\"] == 25\n+        assert out[\"compression.codex_app_server_auto\"] == \"hermes\"\n \n     def test_missing_keys_yield_none(self):\n         \"\"\"Absent config keys must produce None values (still contribute to signature).\"\"\"","@@ -2116,4 +2120,3 @@ def test_soft_release_scheduled_for_evicted_agent(self):\n \n         assert release_calls == [old_agent]\n         runner._cleanup_agent_resources.assert_not_called()\n-"]},{"file":"tests/hermes_cli/test_config.py","hunks":["@@ -1780,3 +1780,30 @@ def test_explicit_config_paths_from_raw_before_normalization(self, tmp_path):\n \n     def test_explicit_config_paths_ignore_empty_sections(self):\n         assert _explicit_config_paths({\"memory\": {}, \"display\": {}}) == set()\n+\n+\n+class TestCodexAppServerAutoConfig:\n+    \"\"\"codex_app_server_auto ships a default and survives migration untouched.\"\"\"\n+\n+    def _write(self, tmp_path, body):\n+        (tmp_path / \"config.yaml\").write_text(body, encoding=\"utf-8\")\n+\n+    def test_default_config_has_native_mode(self):\n+        assert DEFAULT_CONFIG[\"compression\"][\"codex_app_server_auto\"] == \"native\"\n+        assert DEFAULT_CONFIG[\"compression\"][\"codex_gpt55_autoraise\"] is True\n+\n+    def test_preserves_existing_codex_app_server_auto_value(self, tmp_path):\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            self._write(\n+                tmp_path,\n+                \"_config_version: 31\\n\"\n+                \"compression:\\n\"\n+                \"  codex_app_server_auto: hermes\\n\",\n+            )\n+\n+            migrate_config(interactive=False, quiet=True)\n+\n+            raw = yaml.safe_load((tmp_path / \"config.yaml\").read_text())\n+            assert raw[\"compression\"][\"codex_app_server_auto\"] == \"hermes\"\n+\n+"]},{"file":"tests/run_agent/test_codex_app_server_compaction.py","hunks":["@@ -0,0 +1,194 @@\n+from types import SimpleNamespace\n+\n+from agent.codex_runtime import _record_codex_app_server_compaction\n+from agent.conversation_compression import COMPACTION_STATUS, compress_context\n+from agent.transports.codex_app_server_session import TurnResult\n+\n+\n+class FakeCodexSession:\n+    def __init__(self, result):\n+        self.result = result\n+        self.calls = 0\n+        self.closed = False\n+\n+    def compact_thread(self):\n+        self.calls += 1\n+        return self.result\n+\n+    def close(self):\n+        self.closed = True\n+\n+\n+class DummyAgent:\n+    def __init__(\n+        self,\n+        result,\n+        *,\n+        auto_compaction=\"native\",\n+    ):\n+        self.api_mode = \"codex_app_server\"\n+        self.codex_app_server_auto_compaction = auto_compaction\n+        self.session_id = \"hermes-session-1\"\n+        self.platform = \"cli\"\n+        self._cached_system_prompt = \"cached prompt\"\n+        self._codex_session = FakeCodexSession(result)\n+        self.context_compressor = SimpleNamespace(\n+            compression_count=0,\n+            last_compression_rough_tokens=0,\n+            last_prompt_tokens=123,\n+            last_completion_tokens=45,\n+            awaiting_real_usage_after_compression=False,\n+        )\n+        self.statuses = []\n+        self.warnings = []\n+        self.events = []\n+        self.built_prompts = []\n+\n+    def _emit_status(self, message):\n+        self.statuses.append(message)\n+\n+    def _emit_warning(self, message):\n+        self.warnings.append(message)\n+\n+    def _build_system_prompt(self, system_message):\n+        self.built_prompts.append(system_message)\n+        return \"built prompt\"\n+\n+    def event_callback(self, name, payload):\n+        self.events.append((name, payload))\n+\n+\n+def test_codex_app_server_native_auto_mode_leaves_thread_compaction_to_codex():\n+    agent = DummyAgent(\n+        TurnResult(thread_id=\"thread-1\", turn_id=\"compact-turn-1\")\n+    )\n+    messages = [{\"role\": \"user\", \"content\": \"hi\"}]\n+\n+    returned, prompt = compress_context(\n+        agent,\n+        messages,\n+        \"system\",\n+        approx_tokens=[redacted:secret-assignment]\n+        task_id=\"test\",\n+    )\n+\n+    assert returned is messages\n+    assert prompt == \"cached prompt\"\n+    assert agent._codex_session.calls == 0\n+    assert agent.context_compressor.compression_count == 0\n+    assert agent.events == []\n+\n+\n+def test_codex_app_server_manual_compression_routes_to_codex_thread():\n+    agent = DummyAgent(\n+        TurnResult(thread_id=\"thread-1\", turn_id=\"compact-turn-1\")\n+    )\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"hi\"},\n+        {\"role\": \"assistant\", \"content\": \"hello\"},\n+    ]\n+\n+    returned, prompt = compress_context(\n+        agent,\n+        messages,\n+        \"system\",\n+        approx_tokens=[redacted:secret-assignment]\n+        task_id=\"test\",\n+        force=True,\n+    )\n+\n+    assert returned is messages\n+    assert prompt == \"cached prompt\"\n+    assert agent._codex_session.calls == 1\n+    assert agent.context_compressor.compression_count == 1\n+    assert agent.context_compressor.last_compression_rough_tokens == 100000\n+    assert agent.context_compressor.last_prompt_tokens == -1\n+    assert agent.context_compressor.last_completion_tokens == 0\n+    assert agent.context_compressor.awaiting_real_usage_after_compression is True\n+    assert agent.events == [\n+        (\n+            \"session:compress\",\n+            {\n+                \"platform\": \"cli\",\n+                \"session_id\": \"hermes-session-1\",\n+                \"old_session_id\": \"\",\n+                \"in_place\": False,\n+                \"compression_count\": 1,\n+                \"runtime\": \"codex_app_server\",\n+                \"thread_id\": \"thread-1\",\n+                \"turn_id\": \"compact-turn-1\",\n+            },\n+        )\n+    ]\n+\n+\n+def test_codex_app_server_hermes_mode_auto_compression_routes_to_codex_thread():\n+    agent = DummyAgent(\n+        TurnResult(thread_id=\"thread-1\", turn_id=\"compact-turn-1\"),\n+        auto_compaction=\"hermes\",\n+    )\n+    messages = [{\"role\": \"user\", \"content\": \"hi\"}]\n+\n+    returned, prompt = compress_context(\n+        agent,\n+        messages,\n+        \"system\",\n+        approx_tokens=[redacted:secret-assignment]\n+    )\n+\n+    assert returned is messages\n+    assert prompt == \"cached prompt\"\n+    assert agent._codex_session.calls == 1\n+    assert agent.context_compressor.compression_count == 1\n+\n+\n+def test_codex_app_server_compression_failure_preserves_bookkeeping():\n+    agent = DummyAgent(TurnResult(error=\"compact failed\"))\n+    messages = [{\"role\": \"user\", \"content\": \"hi\"}]\n+\n+    returned, prompt = compress_context(\n+        agent,\n+        messages,\n+        \"system\",\n+        approx_tokens=[redacted:secret-assignment]\n+        force=True,\n+    )\n+\n+    assert returned is messages\n+    assert prompt == \"cached prompt\"\n+    assert agent._codex_session.calls == 1\n+    assert agent.context_compressor.compression_count == 0\n+    assert agent.context_compressor.last_prompt_tokens == 123\n+    assert agent.warnings\n+\n+\n+def test_codex_app_server_native_compaction_notice_emits_status_and_event():\n+    agent = DummyAgent(\n+        TurnResult(thread_id=\"thread-1\", turn_id=\"normal-turn-1\")\n+    )\n+    turn = TurnResult(\n+        thread_id=\"thread-1\",\n+        turn_id=\"normal-turn-1\",\n+        compacted=True,\n+    )\n+\n+    recorded = _record_codex_app_server_compaction(agent, turn)\n+\n+    assert recorded is True\n+    assert agent.context_compressor.compression_count == 1\n+    assert agent.statuses == [COMPACTION_STATUS]\n+    assert agent.events == [\n+        (\n+            \"session:compress\",\n+            {\n+                \"platform\": \"cli\",\n+                \"session_id\": \"hermes-session-1\",\n+                \"old_session_id\": \"\",\n+                \"in_place\": False,\n+                \"compression_count\": 1,\n+                \"runtime\": \"codex_app_server\",\n+                \"thread_id\": \"thread-1\",\n+                \"turn_id\": \"normal-turn-1\",\n+            },\n+        )\n+    ]"]},{"file":"tests/run_agent/test_codex_app_server_integration.py","hunks":["@@ -49,7 +49,7 @@ def fake_run_turn(self, user_input: str, **kwargs):\n     )\n \n \n-def _make_codex_agent():\n+def _make_codex_agent(**kwargs):\n     \"\"\"Construct an AIAgent in codex_app_server mode without contacting any\n     real provider. We pass api_mode explicitly so the constructor takes the\n     fast path for direct credentials.\"\"\"","@@ -61,6 +61,7 @@ def _make_codex_agent():\n         quiet_mode=True,\n         skip_context_files=True,\n         skip_memory=True,\n+        **kwargs,\n     )\n \n ","@@ -134,6 +135,46 @@ def fake_run_turn(self, user_input: str, **kwargs):\n         assert agent.context_compressor.last_total_tokens == 130\n         assert agent.context_compressor.context_length == 200000\n \n+    def test_native_codex_compaction_updates_bookkeeping(self, monkeypatch):\n+        def fake_run_turn(self, user_input: str, **kwargs):\n+            return TurnResult(\n+                final_text=\"done\",\n+                projected_messages=[{\"role\": \"assistant\", \"content\": \"done\"}],\n+                turn_id=\"turn-compact-1\",\n+                thread_id=\"thread-compact-1\",\n+                compacted=True,\n+            )\n+\n+        monkeypatch.setattr(CodexAppServerSession, \"run_turn\", fake_run_turn)\n+        monkeypatch.setattr(\n+            CodexAppServerSession, \"ensure_started\", lambda self: \"thread-compact-1\"\n+        )\n+        events = []\n+        agent = _make_codex_agent(event_callback=lambda name, payload: events.append((name, payload)))\n+\n+        with patch.object(agent, \"_spawn_background_review\", return_value=None):\n+            result = agent.run_conversation(\"hello\")\n+\n+        assert result[\"completed\"] is True\n+        assert agent.context_compressor.compression_count == 1\n+        assert agent.context_compressor.last_prompt_tokens == -1\n+        assert agent.context_compressor.awaiting_real_usage_after_compression is True\n+        assert events == [\n+            (\n+                \"session:compress\",\n+                {\n+                    \"platform\": \"\",\n+                    \"session_id\": agent.session_id,\n+                    \"old_session_id\": \"\",\n+                    \"in_place\": False,\n+                    \"compression_count\": 1,\n+                    \"runtime\": \"codex_app_server\",\n+                    \"thread_id\": \"thread-compact-1\",\n+                    \"turn_id\": \"turn-compact-1\",\n+                },\n+            )\n+        ]\n+\n     def test_projected_messages_are_spliced(self, fake_session):\n         agent = _make_codex_agent()\n         with patch.object(agent, \"_spawn_background_review\", return_value=None):"]},{"file":"website/docs/developer-guide/context-compression-and-caching.md","hunks":["@@ -86,6 +86,7 @@ compression:\n   protect_last_n: 20         # Minimum protected tail messages (default: 20)\n   codex_gpt55_autoraise: true  # gpt-5.5 on Codex OAuth: raise trigger to 85% (default: true)\n   codex_gpt55_autoraise_notice: true  # Show the one-time autoraise notice (default: true)\n+  codex_app_server_auto: native  # native|hermes|off for Codex app-server thread compaction\n \n # Summarization model/provider configured under auxiliary:\n auxiliary:","@@ -105,6 +106,7 @@ auxiliary:\n | `protect_first_n` | `3` | (hardcoded) | System prompt + first exchange always preserved |\n | `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` |\n | `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 |\n+| `codex_app_server_auto` | `native` | `native`, `hermes`, `off` | Thread-compaction mode for Codex app-server sessions (see below) |\n \n ### Codex gpt-5.5 threshold autoraise\n ","@@ -131,6 +133,28 @@ To keep the 85% autoraise but hide only the one-time notice:\n hermes config set compression.codex_gpt55_autoraise_notice false\n ```\n \n+### Codex app-server thread compaction\n+\n+Codex app-server sessions (`api_mode: codex_app_server` — the codex CLI/agent\n+runtime) are different from every other route: the codex agent owns the backing\n+thread context, so Hermes' auxiliary summarizer cannot shrink it — rewriting the\n+local transcript mirror leaves the real thread growing unbounded until a hard\n+context reset. For this runtime, compaction goes through the app-server's own\n+mechanism instead:\n+\n+- Manual compaction (`/compress`) asks the app-server to compact the thread\n+  (`thread/compact/start`) and waits for the compaction turn to complete.\n+- Automatic compaction is controlled by `compression.codex_app_server_auto`:\n+  the default `native` lets the app-server decide when to compact and Hermes\n+  records the resulting compaction events (compression counters, session\n+  events). Set `hermes` to let Hermes' compression threshold initiate\n+  app-server compaction, or `off` to disable Hermes-initiated automatic\n+  compaction entirely (codex may still compact natively).\n+\n+Hermes' local transcript is never rewritten on this runtime — state.db records\n+the compaction boundary while the visible transcript stays intact. All other\n+routes (including Codex OAuth chat sessions) keep Hermes' summary compressor.\n+\n ### Computed Values (for a 200K context model at defaults)\n \n ```\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}