# NousResearch/hermes-agent · Codex compaction API integration for context compression

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #60114. Verify it addresses your case.
- **Recommended:** read PR #60114 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #33130: \[Feature\]: Use OpenAI Codex Compaction API instead of internal Compress for context compaction.

## Prior & competing PRs

- **#60114: feat(compression): route Codex app-server compaction through thread/compact/start (#36801)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `agent/agent_init.py`

    ```diff
    @@ -1570,6 +1570,16 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
         compression_in_place = is_truthy_value(
             _compression_cfg.get("in_place"), default=False
         )
    +    codex_app_server_auto_compaction = str(
    +        _compression_cfg.get("codex_app_server_auto", "native") or "native"
    +    ).lower()
    +    if codex_app_server_auto_compaction not in {"native", "hermes", "off"}:
    +        _ra().logger.warning(
    +            "Invalid compression.codex_app_server_auto=%r; using 'native'. "
    +            "Valid values are: native, hermes, off.",
    +            codex_app_server_auto_compaction,
    +        )
    +        codex_app_server_auto_compaction = "native"
     
         # Read optional explicit context_length override for the auxiliary
         # compression model. Custom endpoints often cannot report this via
    ```

    ```diff
    @@ -1824,6 +1834,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
                 pass
         agent.compression_enabled = compression_enabled
         agent.compression_in_place = compression_in_place
    +    agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
     
         # Reject models whose context window is below the minimum required
         # for reliable tool-calling workflows (64K tokens).
    ```
  - `agent/codex_runtime.py`

    ```diff
    @@ -228,6 +228,76 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
         }
     
     
    +def _record_codex_app_server_compaction(
    +    agent,
    +    turn,
    +    *,
    +    approx_tokens: int | None = None,
    +    force: bool = False,
    +) -> bool:
    +    """Record a Codex-native context compaction boundary in Hermes state.
    +
    +    The app-server owns the compacted thread context, so Hermes should not
    +    rewrite local transcript rows here; state.db records the boundary via the
    +    session event/usage counters while preserving the visible transcript.
    +    """
    +    if not force and not getattr(turn, "compacted", False):
    +        return False
    +
    +    thread_id = getattr(turn, "thread_id", None) or ""
    +    turn_id = getattr(turn, "turn_id", None) or ""
    +    logger.info(
    +        "codex app-server compaction observed: session=%s thread=%s turn=%s force=%s",
    +        getattr(agent, "session_id", None) or "none",
    +        thread_id,
    +        turn_id,
    +        force,
    +    )
    +    if not force:
    +        try:
    +            from agent.conversation_compression import COMPACTION_STATUS
    +
    +            agent._emit_status(COMPACTION_STATUS)
    +        except Exception:
    +            pass
    +
    +    compressor = getattr(agent, "context_compressor", None)
    +    if compressor is not None:
    +        compressor.compression_count = getattr(
    +            compressor, "compression_count", 0
    +        ) + 1
    +        compressor.last_compression_rough_tokens = [redacted:secret-assignment] or 0
    +        if not getattr(turn, "token_usage_last", None):
    +            compressor.last_prompt_tokens = -1
    +            compressor.last_completion_tokens = 0
    +            compressor.awaiting_real_usage_after_compression = True
    +
    +    agent._last_compaction_in_place = False
    +    try:
    +        if getattr(agent, "event_callback", None):
    +            agent.event_callback(
    +                "session:compress",
    +                {
    +                    "platform": getattr(agent, "platform", None) or "",
    +                    "session_id": getattr(agent, "session_id", None) or "",
    +                    "old_session_id": "",
    +                    "in_place": False,
    +                    "compression_count": getattr(
    +                        compressor, "compression_count", 0
    +                    )
    +                    if compressor is not None
    +                    else 0,
    +                    "runtime": "codex_app_server",
    +                    "thread_id": thread_id,
    +                    "turn_id": turn_id,
    +                },
    +            )
    +    except Exception:
    +        logger.debug("event_callback error on codex session:compress", exc_info=True)
    +
    +    return True
    +
    +
     def run_codex_app_server_turn(
         agent,
         *,
    ```

    ```diff
    @@ -393,6 +463,7 @@ def _on_codex_event(note: dict) -> None:
         agent._iters_since_skill = (
             getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
         )
    +    _record_codex_app_server_compaction(agent, turn)
         usage_result = _record_codex_app_server_usage(agent, turn)
         api_calls = 1
     
    ```
  - `agent/conversation_compression.py`

    ```diff
    @@ -465,6 +465,21 @@ def compress_context(
             prompt — the session is NOT rotated.  Callers should detect the
             no-op via ``len(returned) == len(input)`` and stop the retry loop.
         """
    +    # Codex app-server sessions: the codex agent owns the real thread context;
    +    # Hermes' summarizer would only rewrite a local mirror without shrinking
    +    # the actual thread (#36801). Route compaction to the app server's own
    +    # thread/compact mechanism. Behavior is controlled by
    +    # ``compression.codex_app_server_auto`` (native|hermes|off).
    +    if getattr(agent, "api_mode", None) == "codex_app_server":
    +        return _compress_context_via_codex_app_server(
    +            agent,
    +            messages,
    +            system_message,
    +            approx_tokens=[redacted:secret-assignment]
    +            task_id=task_id,
    +            force=force,
    +        )
    +
         # Lazy feasibility check — run the auxiliary-provider probe + context
         # length lookup just-in-time on the first compression attempt instead of
         # at AIAgent.__init__. Saves ~400ms cold off every short session that
    ```

    ```diff
    @@ -971,6 +986,122 @@ def _release_lock() -> None:
             _release_lock()
     
     
    +def _compress_context_via_codex_app_server(
    +    agent: Any,
    +    messages: list,
    +    system_message: Optional[str],
    +    *,
    +    approx_tokens: [redacted:secret-assignment] = None,
    +    task_id: str = "default",
    +    force: bool = False,
    +) -> Tuple[list, str]:
    +    """Route compaction to Codex app-server for Codex-owned threads.
    +
    +    Hermes' normal compressor rewrites the local OpenAI-style transcript.
    +    That does not shrink the actual Codex app-server thread context. For this
    +    runtime, ask Codex to compact its own thread and keep Hermes' transcript
    +    unchanged.
    +    """
    +    auto_mode = str(
    +        getattr(agent, "codex_app_server_auto_compaction", "native") or "native"
    +    ).lower()
    +    if auto_mode not in {"native", "hermes", "off"}:
    +        auto_mode = "native"
    +    if not force and auto_mode != "hermes":
    +        logger.info(
    +            "codex app-server compaction skipped: mode=%s force=false "
    +            "(session=%s messages=%d tokens=~%s)",
    +            auto_mode,
    +            getattr(agent, "session_id", None) or "none",
    +            len(messages),
    +            f"{approx_tokens:,}" if approx_tokens else "unknown",
    +        )
    +        existing_prompt = getattr(agent, "_cached_system_prompt", None)
    +        if not existing_prompt:
    +            existing_prompt = agent._build_system_prompt(system_message)
    +        return messages, existing_prompt
    +
    +    codex_session = getattr(agent, "_codex_session", None)
    +    if codex_session is None:
    +        logger.info(
    +            "codex app-server compaction skipped: no active codex thread "
    +            "(session=%s messages=%d tokens=~%s)",
    +            getattr(agent, "session_id", None) or "none",
    +            len(messages),
    +            f"{approx_tokens:,}" if approx_tokens else "unknown",
    +        )
    +        existing_prompt = getattr(agent, "_cached_system_prompt", None)
    +        if not existing_prompt:
    +            existing_prompt = agent._build_system_prompt(system_message)
    +        return messages, existing_prompt
    +
    +    logger.info(
    +        "codex app-server compaction started: session=%s messages=%d tokens=~%s",
    +        getattr(agent, "session_id", None) or "none",
    +        len(messages),
    +        f"{approx_tokens:,}" if approx_tokens else "unknown",
    +    )
    +    try:
    +        agent._emit_status(COMPACTION_STATUS)
    +    except Exception:
    +        pass
    +
    +    result = codex_session.compact_thread()
    +    if getattr(result, "should_retire", False):
    +        try:
    +            codex_session.close()
    +        except Exception:
    +            pass
    +        agent._codex_session = None
    +
    +    if getattr(result, "error", None):
    +        try:
    +            agent._emit_warning(
    +                f"⚠ Codex app-server compaction failed: {result.error}"
    +            )
    +        except Exception:
    +            pass
    +        existing_prompt = getattr(agent, "_cached_system_prompt", None)
    +        if not existing_prompt:
    +            existing_prompt = agent._build_system_prompt(system_message)
    +        return messages, existing_prompt
    +
    +    try:
    +        from agent.codex_runtime import (
    +            _record_codex_app_server_compaction,
    +            _record_codex_app_server_usage,
    +        )
    +
    +        _record_codex_app_server_compaction(
    +            agent,
    +            result,
    +            approx_tokens=[redacted:secret-assignment]
    +            force=True,
    +        )
    +        if getattr(result, "token_usage_last", None):
    +            _record_codex_app_server_usage(agent, result)
    +    except Exception:
    +        logger.debug("codex compaction bookkeeping failed", exc_info=True)
    +
    +    try:
    +        from tools.file_tools import reset_file_dedup
    +
    +        reset_file_dedup(task_id)
    +    except Exception:
    +        pass
    +
    +    logger.info(
    +        "codex app-server compaction done: session=%s thread=%s turn=%s",
    +        getattr(agent, "session_id", None) or "none",
    +        getattr(result, "thread_id", None) or "",
    +        getattr(result, "turn_id", None) or "",
    +    )
    +    existing_prompt = getattr(agent, "_cached_system_prompt", None)
    +    if not existing_prompt:
    +        existing_prompt = agent._build_system_prompt(system_message)
    +    return messages, existing_prompt
    +
    +
     def try_shrink_image_parts_in_messages(
         api_messages: list,
         *,
    ```
  - `agent/transports/codex_app_server_session.py`

    ```diff
    @@ -75,6 +75,7 @@ class TurnResult:
         token_usage_last: [redacted:secret-assignment] Any]] = None
         token_usage_total: [redacted:secret-assignment] Any]] = None
         model_context_window: Optional[int] = None
    +    compacted: bool = False
         # Hint to the caller that the underlying codex subprocess is likely
         # wedged (turn-level timeout fired, post-tool watchdog tripped, or
         # token-refresh failure killed the child). The caller should retire
    ```

    ```diff
    @@ -505,6 +506,7 @@ def run_turn(
                         if pending is None:
                             break
                         _apply_token_usage_notification(result, pending)
    +                    _apply_compaction_notification(result, pending)
                         self._track_pending_file_change(pending)
                         proj = projector.project(pending)
                         if proj.messages:
    ```

    ```diff
    @@ -541,6 +543,7 @@ def run_turn(
                         logger.debug("on_event callback raised", exc_info=True)
     
                 _apply_token_usage_notification(result, note)
    +            _apply_compaction_notification(result, note)
     
                 # Track in-progress fileChange items so the approval bridge
                 # can surface a real change summary when codex requests
    ```

    ```diff
    @@ -632,6 +635,154 @@ def run_turn(
     
             return result
     
    +    def compact_thread(
    +        self,
    +        *,
    +        turn_timeout: float = 600.0,
    +        notification_poll_timeout: float = 0.25,
    +    ) -> TurnResult:
    +        """Trigger Codex-native history compaction for the current thread.
    +
    +        `thread/compact/start` returns immediately; the actual compaction
    +        progress streams through the same turn/item notifications as a normal
    +        turn. We wait for the matching `turn/completed` so callers can treat a
    +        successful return as a completed compaction boundary.
    +        """
    +        result = TurnResult()
    +        try:
    +            self.ensure_started()
    +        except (CodexAppServerError, TimeoutError) as exc:
    +            result.error = self._format_error_with_stderr(
    +                "codex app-server startup failed", exc
    +            )
    +            result.should_retire = True
    +            return result
    +
    +        assert self._client is not None and self._thread_id is not None
    +        result.thread_id = self._thread_id
    +        self._interrupt_event.clear()
    +        projector = CodexEventProjector()
    +
    +        try:
    +            self._client.request(
    +                "thread/compact/start",
    +                {"threadId": self._thread_id},
    +                timeout=10,
    +            )
    +        except CodexAppServerError as exc:
    +            stderr_blob = "\n".join(self._client.stderr_tail(40))
    +            hint = _classify_oauth_failure(exc.message, stderr_blob)
    +            if hint is not None:
    +                result.error = hint
    +                result.should_retire = True
    +            else:
    +                result.error = self._format_error_with_stderr(
    +                    "thread/compact/start failed", exc
    +                )
    +            return result
    +        except TimeoutError as exc:
    +            stderr_blob = "\n".join(self._client.stderr_tail(40))
    +            hint = _classify_oauth_failure(stderr_blob)
    +            result.error = hint or self._format_error_with_stderr(
    +                "thread/compact/start timed out", exc
    +            )
    +            result.should_retire = True
    +            return result
    +
    +        deadline = time.monotonic() + turn_timeout
    +        turn_complete = False
    +
    +        while time.monotonic() < deadline and not turn_complete:
    +            if self._interrupt_event.is_set():
    +                self._issue_interrupt(result.turn_id)
    +                result.interrupted = True
    +                break
    +
    +            if not self._client.is_alive():
    +                stderr_blob = "\n".join(self._client.stderr_tail(60))
    +                hint = _classify_oauth_failure(stderr_blob)
    +                if hint is not None:
    +                    result.error = hint
    +                else:
    +                    result.error = self._format_error_with_stderr(
    +                        "codex app-server subprocess exited unexpectedly",
    +                        tail_lines=20,
    +                    )
    +                result.should_retire = True
    +                break
    +
    +            sreq = self._client.take_server_request(timeout=0)
    +            if sreq is not None:
    +                self._handle_server_request(sreq)
    +                continue
    +
    +            note = self._client.take_notification(
    +                timeout=notification_poll_timeout
    +            )
    +            if note is None:
    +                continue
    +
    +            method = note.get("method", "")
    +            if self._on_event is not None:
    +                try:
    +                    self._on_event(note)
    +                except Exception:  # pragma: no cover - display callback
    +                    logger.debug("on_event callback raised", exc_info=True)
    +
    +            _apply_token_usage_notification(result, note)
    +            _apply_compaction_notification(result, note)
    +            self._track_pending_file_change(note)
    +
    +            projection = projector.project(note)
    +            if projection.messages:
    +                result.projected_messages.extend(projection.messages)
    +            if projection.is_tool_iteration:
    +                result.tool_iterations += 1
    +            if projection.final_text is not None:
    +                result.final_text = projection.final_text
    +                if _has_turn_aborted_marker(projection.final_text):
    +                    turn_complete = True
    +                    result.interrupted = True
    +                    result.error = (
    +                        result.error or "codex reported turn_aborted"
    +                    )
    +
    +            if method == "turn/started":
    +                turn_obj = (note.get("params") or {}).get("turn") or {}
    +                result.turn_id = turn_obj.get("id") or result.turn_id
    +            elif method == "turn/completed":
    +                turn_complete = True
    +                turn_obj = (note.get("params") or {}).get("turn") or {}
    +                result.turn_id = turn_obj.get("id") or result.turn_id
    +                turn_status = turn_obj.get("status")
    +                if turn_status and turn_status not in {"completed", "interrupted"}:
    +                    err_obj = turn_obj.get("error")
    +                    if err_obj:
    +                        err_msg = _format_responses_error(err_obj, str(turn_status))
    +                        stderr_blob = "\n".join(
    +                            self._client.stderr_tail(40)
    +                        )
    +                        hint = _classify_oauth_failure(err_msg, stderr_blob)
    +                        if hint is not None:
    +                            result.error = hint
    +                            result.should_retire = True
    +                        else:
    +                            result.error = self._format_error_with_stderr(
    +                                f"compact turn ended status={turn_status}",
    +                                err_msg,
    +                            )
    +
    +        if not turn_complete and not result.interrupted:
    +            self._issue_interrupt(result.turn_id)
    +            result.interrupted = True
    +            if not result.error:
    +                result.error = self._format_error_with_stderr(
    +                    f"compact turn timed out after {turn_timeout}s"
    +                )
    +            result.should_retire = True
    +
    +        return result
    +
         # ---------- internals ----------
     
         def _issue_interrupt(self, turn_id: Optional[str]) -> None:
    ```

    ```diff
    @@ -845,6 +996,38 @@ def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
             result.model_context_window = window
     
     
    +def _apply_compaction_notification(result: TurnResult, note: dict) -> None:
    +    """Capture Codex-native context compaction boundaries.
    +
    +    Recent app-server builds expose compaction as a ContextCompaction item.
    +    Older builds also emit the deprecated thread/compacted notification. Both
    +    mean the underlying Codex thread history has been compacted.
    +    """
    +    if not isinstance(note, dict):
    +        return
    +    method = note.get("method") or ""
    +    params = note.get("params") or {}
    +    if not isinstance(params, dict):
    +        return
    +
    +    if method == "thread/compacted":
    +        result.compacted = True
    +        result.thread_id = params.get("threadId") or result.thread_id
    +        result.turn_id = params.get("turnId") or result.turn_id
    +        return
    +
    +    if method not in {"item/started", "item/completed"}:
    +        return
    +
    +    item = params.get("item") or {}
    +    if not isinstance(item, dict) or item.get("type") != "contextCompaction":
    +        return
    +
    +    result.compacted = True
    +    result.thread_id = params.get("threadId") or result.thread_id
    +    result.turn_id = params.get("turnId") or result.turn_id
    +
    +
     def _approval_choice_to_codex_decision(choice: str) -> str:
         """Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
         / FileChangeApprovalDecision wire values.
    ```
  - `agent/turn_context.py`

    ```diff
    @@ -369,6 +369,20 @@ def build_turn_context(
                 lambda _tokens: [redacted:secret-assignment]
             )
             _preflight_deferred = _defer_preflight(_preflight_tokens)
    +        # Codex app-server threads are compacted by the codex agent itself;
    +        # Hermes only initiates compaction in "hermes" mode (#36801).
    +        _codex_native_auto = (
    +            getattr(agent, "api_mode", None) == "codex_app_server"
    +            and str(
    +                getattr(
    +                    agent,
    +                    "codex_app_server_auto_compaction",
    +                    "native",
    +                )
    +                or "native"
    +            ).lower()
    +            in {"native", "off"}
    +        )
     
             if not _preflight_deferred:
                 _last = _compressor.last_prompt_tokens
    ```

    ```diff
    @@ -397,6 +411,12 @@ def build_turn_context(
                     int(_compression_cooldown.get("remaining_seconds", 0.0)),
                     agent.session_id or "none",
                 )
    +        elif _codex_native_auto:
    +            logger.info(
    +                "Skipping Hermes preflight compression for codex app-server "
    +                "(mode=%s); Hermes will not start thread compaction here.",
    +                getattr(agent, "codex_app_server_auto_compaction", "native"),
    +            )
             elif _compressor.should_compress(_preflight_tokens):
                 logger.info(
                     "Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
    ```
  - `cli-config.yaml.example`

    ```diff
    @@ -410,7 +410,11 @@ compression:
       # Trigger compression at this % of model's context limit (default: 0.50 = 50%)
       # Lower values = more aggressive compression, higher values = compress later
       threshold: 0.50
    -  
    +
    +  # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85%
    +  # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold.
    +  codex_gpt55_autoraise: true
    +
       # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%)
       # e.g. 20% of 50% threshold = 10% of total context kept as recent messages.
       # Summary output is separately capped at 12K tokens (Gemini output limit).
    ```

    ```diff
    @@ -422,6 +426,14 @@ compression:
       # compression of older turns.
       protect_last_n: 20
     
    +  # Codex app-server (codex CLI runtime) thread-compaction mode. The codex
    +  # agent owns the real thread context on this runtime, so Hermes' summarizer
    +  # cannot shrink it — compaction goes through the app server instead.
    +  #   native = let Codex decide when to compact its own thread (default)
    +  #   hermes = let Hermes threshold trigger Codex thread/compact/start
    +  #   off    = Hermes will not auto-trigger compaction; Codex may still compact natively
    +  codex_app_server_auto: native
    +
       # 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
    ```
  - `gateway/run.py`

    ```diff
    @@ -15321,6 +15321,8 @@ async def _run_process_watcher(self, watcher: dict) -> None:
             ("model", "max_tokens"),
             ("compression", "enabled"),
             ("compression", "threshold"),
    +        ("compression", "codex_gpt55_autoraise"),
    +        ("compression", "codex_app_server_auto"),
             ("compression", "target_ratio"),
             ("compression", "protect_last_n"),
             ("agent", "disabled_toolsets"),
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -1377,6 +1377,14 @@ def _ensure_hermes_home_managed(home: Path):
                                           # autoraise banner. Set False to keep the
                                           # 85% threshold autoraise but suppress the
                                           # user-facing notice in CLI/gateway output.
    +        "codex_app_server_auto": "native",  # Codex app-server (codex CLI runtime) thread
    +                                      # compaction mode. The codex agent owns the real
    +                                      # thread context, so Hermes' summarizer cannot
    +                                      # shrink it (#36801). native = codex decides when
    +                                      # to compact its own thread (default); hermes =
    +                                      # Hermes' compression threshold triggers
    +                                      # thread/compact/start; off = never auto-trigger
    +                                      # (codex may still compact natively).
             "in_place": True,             # When True, compaction rewrites the message
                                           # list and rebuilds the system prompt WITHOUT
                                           # rotating the session id — the conversation
    ```
  - `scripts/release.py`

    ```diff
    @@ -162,6 +162,7 @@
         "65363919+coygeek@users.noreply.github.com": "coygeek",  # PR #37951 salvage (fail closed when provider env blocklist import fails; #37950)
         "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)
         "[redacted:email]": "jmmaloney4",  # PR #25206 salvage (re-select credential pool on primary runtime restore; #25205)
    +    "hmirin@users.noreply.github.com": "hmirin",
         "[redacted:email]": "dalenguyen",  # PR #53678 salvage (strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env; #23473)
         "[redacted:email]": "HexLab98",  # PR #53863 salvage (env-only proxy policy for auxiliary OpenAI clients on macOS; #53702)
         "[redacted:email]": "Blaryxoff",  # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396)
    ```
  - `tests/agent/transports/test_codex_app_server_session.py`

    ```diff
    @@ -445,6 +445,107 @@ def test_failed_turn_records_error_from_turn_completed(self):
             r = s.run_turn("x", turn_timeout=1.0)
             assert r.error and "model error" in r.error
     
    +    def test_run_turn_records_native_compaction_item(self):
    +        client = FakeClient()
    +        client.queue_notification(
    +            "item/completed",
    +            threadId="thread-fake-001",
    +            turnId="turn-fake-001",
    +            item={"type": "contextCompaction", "id": "compact-item-1"},
    +        )
    +        client.queue_notification(
    +            "turn/completed", threadId="thread-fake-001",
    +            turn={"id": "turn-fake-001", "status": "completed", "error": None},
    +        )
    +
    +        r = make_session(client).run_turn("x", turn_timeout=1.0)
    +
    +        assert r.compacted is True
    +        assert r.thread_id == "thread-fake-001"
    +        assert r.turn_id == "turn-fake-001"
    +
    +    def test_run_turn_records_deprecated_thread_compacted_notification(self):
    +        client = FakeClient()
    +        client.queue_notification(
    +            "thread/compacted",
    +            threadId="thread-fake-001",
    +            turnId="turn-fake-001",
    +        )
    +        client.queue_notification(
    +            "turn/completed", threadId="thread-fake-001",
    +            turn={"id": "turn-fake-001", "status": "completed", "error": None},
    +        )
    +
    +        r = make_session(client).run_turn("x", turn_timeout=1.0)
    +
    +        assert r.compacted is True
    +
    +
    +class TestCompactThread:
    +    def test_compact_thread_sends_rpc_and_waits_for_completion(self):
    +        client = FakeClient()
    +        client.queue_notification(
    +            "turn/started",
    +            threadId="thread-fake-001",
    +            turn={"id": "compact-turn-1"},
    +        )
    +        client.queue_notification(
    +            "item/completed",
    +            threadId="thread-fake-001",
    +            turnId="compact-turn-1",
    +            item={"type": "contextCompaction", "id": "compact-item-1"},
    +        )
    +        client.queue_notification(
    +            "item/completed",
    +            threadId="thread-fake-001",
    +            turnId="compact-turn-1",
    +            item={"type": "agentMessage", "id": "m1", "text": "compacted"},
    +        )
    +        client.queue_notification(
    +            "thread/tokenUsage/updated",
    +            threadId="thread-fake-001",
    +            turnId="compact-turn-1",
    +            tokenUsage={
    +                "last": {"inputTokens": 10, "outputTokens": 2, "totalTokens": 12},
    +                "total": {"inputTokens": 100, "outputTokens": 20, "totalTokens": 120},
    +                "modelContextWindow": 200000,
    +            },
    +        )
    +        client.queue_notification(
    +            "turn/completed",
    +            threadId="thread-fake-001",
    +            turn={"id": "compact-turn-1", "status": "completed", "error": None},
    +        )
    +
    +        r = make_session(client).compact_thread(turn_timeout=2.0)
    +
    +        assert ("thread/compact/start", {"threadId": "thread-fake-001"}) in client.requests
    +        assert r.error is None
    +        assert r.thread_id == "thread-fake-001"
    +        assert r.turn_id == "compact-turn-1"
    +        assert r.compacted is True
    +        assert r.final_text == "compacted"
    +        assert r.token_usage_last["totalTokens"] == 12
    +        assert r.model_context_window == 200000
    +
    +    def test_compact_thread_failure_returns_error(self):
    +        client = FakeClient()
    +        from agent.transports.codex_app_server import CodexAppServerError
    +
    +        def boom(method, params):
    +            if method == "thread/compact/start":
    +                raise CodexAppServerError(code=-32603, message="compact unavailable")
    +            if method == "thread/start":
    +                return {"thread": {"id": "thread-fake-001"}}
    +            return {}
    +
    +        client._request_handler = boom
    +        r = make_session(client).compact_thread(turn_timeout=2.0)
    +
    +        assert r.error is not None
    +        assert "compact unavailable" in r.error
    +        assert r.should_retire is False
    +
     
     # ---- approval bridge ----
     
    ```
  - `tests/gateway/test_agent_cache.py`

    ```diff
    @@ -228,16 +228,20 @@ def test_reads_compression_subkeys(self):
                     "compression": {
                         "enabled": False,
                         "threshold": 0.6,
    +                    "codex_gpt55_autoraise": False,
                         "target_ratio": 0.3,
                         "protect_last_n": 25,
    +                    "codex_app_server_auto": "hermes",
                         "some_other_key": "ignored",
                     }
                 }
             )
             assert out["compression.enabled"] is False
             assert out["compression.threshold"] == 0.6
    +        assert out["compression.codex_gpt55_autoraise"] is False
             assert out["compression.target_ratio"] == 0.3
             assert out["compression.protect_last_n"] == 25
    +        assert out["compression.codex_app_server_auto"] == "hermes"
     
         def test_missing_keys_yield_none(self):
             """Absent config keys must produce None values (still contribute to signature)."""
    ```

    ```diff
    @@ -2116,4 +2120,3 @@ def test_soft_release_scheduled_for_evicted_agent(self):
     
             assert release_calls == [old_agent]
             runner._cleanup_agent_resources.assert_not_called()
    -
    ```
  - `tests/hermes_cli/test_config.py`

    ```diff
    @@ -1780,3 +1780,30 @@ def test_explicit_config_paths_from_raw_before_normalization(self, tmp_path):
     
         def test_explicit_config_paths_ignore_empty_sections(self):
             assert _explicit_config_paths({"memory": {}, "display": {}}) == set()
    +
    +
    +class TestCodexAppServerAutoConfig:
    +    """codex_app_server_auto ships a default and survives migration untouched."""
    +
    +    def _write(self, tmp_path, body):
    +        (tmp_path / "config.yaml").write_text(body, encoding="utf-8")
    +
    +    def test_default_config_has_native_mode(self):
    +        assert DEFAULT_CONFIG["compression"]["codex_app_server_auto"] == "native"
    +        assert DEFAULT_CONFIG["compression"]["codex_gpt55_autoraise"] is True
    +
    +    def test_preserves_existing_codex_app_server_auto_value(self, tmp_path):
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
    +            self._write(
    +                tmp_path,
    +                "_config_version: 31\n"
    +                "compression:\n"
    +                "  codex_app_server_auto: hermes\n",
    +            )
    +
    +            migrate_config(interactive=False, quiet=True)
    +
    +            raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
    +            assert raw["compression"]["codex_app_server_auto"] == "hermes"
    +
    +
    ```
  - `tests/run_agent/test_codex_app_server_compaction.py`

    ```diff
    @@ -0,0 +1,194 @@
    +from types import SimpleNamespace
    +
    +from agent.codex_runtime import _record_codex_app_server_compaction
    +from agent.conversation_compression import COMPACTION_STATUS, compress_context
    +from agent.transports.codex_app_server_session import TurnResult
    +
    +
    +class FakeCodexSession:
    +    def __init__(self, result):
    +        self.result = result
    +        self.calls = 0
    +        self.closed = False
    +
    +    def compact_thread(self):
    +        self.calls += 1
    +        return self.result
    +
    +    def close(self):
    +        self.closed = True
    +
    +
    +class DummyAgent:
    +    def __init__(
    +        self,
    +        result,
    +        *,
    +        auto_compaction="native",
    +    ):
    +        self.api_mode = "codex_app_server"
    +        self.codex_app_server_auto_compaction = auto_compaction
    +        self.session_id = "hermes-session-1"
    +        self.platform = "cli"
    +        self._cached_system_prompt = "cached prompt"
    +        self._codex_session = FakeCodexSession(result)
    +        self.context_compressor = SimpleNamespace(
    +            compression_count=0,
    +            last_compression_rough_tokens=0,
    +            last_prompt_tokens=123,
    +            last_completion_tokens=45,
    +            awaiting_real_usage_after_compression=False,
    +        )
    +        self.statuses = []
    +        self.warnings = []
    +        self.events = []
    +        self.built_prompts = []
    +
    +    def _emit_status(self, message):
    +        self.statuses.append(message)
    +
    +    def _emit_warning(self, message):
    +        self.warnings.append(message)
    +
    +    def _build_system_prompt(self, system_message):
    +        self.built_prompts.append(system_message)
    +        return "built prompt"
    +
    +    def event_callback(self, name, payload):
    +        self.events.append((name, payload))
    +
    +
    +def test_codex_app_server_native_auto_mode_leaves_thread_compaction_to_codex():
    +    agent = DummyAgent(
    +        TurnResult(thread_id="thread-1", turn_id="compact-turn-1")
    +    )
    +    messages = [{"role": "user", "content": "hi"}]
    +
    +    returned, prompt = compress_context(
    +        agent,
    +        messages,
    +        "system",
    +        approx_tokens=[redacted:secret-assignment]
    +        task_id="test",
    +    )
    +
    +    assert returned is messages
    +    assert prompt == "cached prompt"
    +    assert agent._codex_session.calls == 0
    +    assert agent.context_compressor.compression_count == 0
    +    assert agent.events == []
    +
    +
    +def test_codex_app_server_manual_compression_routes_to_codex_thread():
    +    agent = DummyAgent(
    +        TurnResult(thread_id="thread-1", turn_id="compact-turn-1")
    +    )
    +    messages = [
    +        {"role": "user", "content": "hi"},
    +        {"role": "assistant", "content": "hello"},
    +    ]
    +
    +    returned, prompt = compress_context(
    +        agent,
    +        messages,
    +        "system",
    +        approx_tokens=[redacted:secret-assignment]
    +        task_id="test",
    +        force=True,
    +    )
    +
    +    assert returned is messages
    +    assert prompt == "cached prompt"
    +    assert agent._codex_session.calls == 1
    +    assert agent.context_compressor.compression_count == 1
    +    assert agent.context_compressor.last_compression_rough_tokens == 100000
    +    assert agent.context_compressor.last_prompt_tokens == -1
    +    assert agent.context_compressor.last_completion_tokens == 0
    +    assert agent.context_compressor.awaiting_real_usage_after_compression is True
    +    assert agent.events == [
    +        (
    +            "session:compress",
    +            {
    +                "platform": "cli",
    +                "session_id": "hermes-session-1",
    +                "old_session_id": "",
    +                "in_place": False,
    +                "compression_count": 1,
    +                "runtime": "codex_app_server",
    +                "thread_id": "thread-1",
    +                "turn_id": "compact-turn-1",
    +            },
    +        )
    +    ]
    +
    +
    +def test_codex_app_server_hermes_mode_auto_compression_routes_to_codex_thread():
    +    agent = DummyAgent(
    +        TurnResult(thread_id="thread-1", turn_id="compact-turn-1"),
    +        auto_compaction="hermes",
    +    )
    +    messages = [{"role": "user", "content": "hi"}]
    +
    +    returned, prompt = compress_context(
    +        agent,
    +        messages,
    +        "system",
    +        approx_tokens=[redacted:secret-assignment]
    +    )
    +
    +    assert returned is messages
    +    assert prompt == "cached prompt"
    +    assert agent._codex_session.calls == 1
    +    assert agent.context_compressor.compression_count == 1
    +
    +
    +def test_codex_app_server_compression_failure_preserves_bookkeeping():
    +    agent = DummyAgent(TurnResult(error="compact failed"))
    +    messages = [{"role": "user", "content": "hi"}]
    +
    +    returned, prompt = compress_context(
    +        agent,
    +        messages,
    +        "system",
    +        approx_tokens=[redacted:secret-assignment]
    +        force=True,
    +    )
    +
    +    assert returned is messages
    +    assert prompt == "cached prompt"
    +    assert agent._codex_session.calls == 1
    +    assert agent.context_compressor.compression_count == 0
    +    assert agent.context_compressor.last_prompt_tokens == 123
    +    assert agent.warnings
    +
    +
    +def test_codex_app_server_native_compaction_notice_emits_status_and_event():
    +    agent = DummyAgent(
    +        TurnResult(thread_id="thread-1", turn_id="normal-turn-1")
    +    )
    +    turn = TurnResult(
    +        thread_id="thread-1",
    +        turn_id="normal-turn-1",
    +        compacted=True,
    +    )
    +
    +    recorded = _record_codex_app_server_compaction(agent, turn)
    +
    +    assert recorded is True
    +    assert agent.context_compressor.compression_count == 1
    +    assert agent.statuses == [COMPACTION_STATUS]
    +    assert agent.events == [
    +        (
    +            "session:compress",
    +            {
    +                "platform": "cli",
    +                "session_id": "hermes-session-1",
    +                "old_session_id": "",
    +                "in_place": False,
    +                "compression_count": 1,
    +                "runtime": "codex_app_server",
    +                "thread_id": "thread-1",
    +                "turn_id": "normal-turn-1",
    +            },
    +        )
    +    ]
    ```
  - `tests/run_agent/test_codex_app_server_integration.py`

    ```diff
    @@ -49,7 +49,7 @@ def fake_run_turn(self, user_input: str, **kwargs):
         )
     
     
    -def _make_codex_agent():
    +def _make_codex_agent(**kwargs):
         """Construct an AIAgent in codex_app_server mode without contacting any
         real provider. We pass api_mode explicitly so the constructor takes the
         fast path for direct credentials."""
    ```

    ```diff
    @@ -61,6 +61,7 @@ def _make_codex_agent():
             quiet_mode=True,
             skip_context_files=True,
             skip_memory=True,
    +        **kwargs,
         )
     
     
    ```

    ```diff
    @@ -134,6 +135,46 @@ def fake_run_turn(self, user_input: str, **kwargs):
             assert agent.context_compressor.last_total_tokens == 130
             assert agent.context_compressor.context_length == 200000
     
    +    def test_native_codex_compaction_updates_bookkeeping(self, monkeypatch):
    +        def fake_run_turn(self, user_input: str, **kwargs):
    +            return TurnResult(
    +                final_text="done",
    +                projected_messages=[{"role": "assistant", "content": "done"}],
    +                turn_id="turn-compact-1",
    +                thread_id="thread-compact-1",
    +                compacted=True,
    +            )
    +
    +        monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
    +        monkeypatch.setattr(
    +            CodexAppServerSession, "ensure_started", lambda self: "thread-compact-1"
    +        )
    +        events = []
    +        agent = _make_codex_agent(event_callback=lambda name, payload: events.append((name, payload)))
    +
    +        with patch.object(agent, "_spawn_background_review", return_value=None):
    +            result = agent.run_conversation("hello")
    +
    +        assert result["completed"] is True
    +        assert agent.context_compressor.compression_count == 1
    +        assert agent.context_compressor.last_prompt_tokens == -1
    +        assert agent.context_compressor.awaiting_real_usage_after_compression is True
    +        assert events == [
    +            (
    +                "session:compress",
    +                {
    +                    "platform": "",
    +                    "session_id": agent.session_id,
    +                    "old_session_id": "",
    +                    "in_place": False,
    +                    "compression_count": 1,
    +                    "runtime": "codex_app_server",
    +                    "thread_id": "thread-compact-1",
    +                    "turn_id": "turn-compact-1",
    +                },
    +            )
    +        ]
    +
         def test_projected_messages_are_spliced(self, fake_session):
             agent = _make_codex_agent()
             with patch.object(agent, "_spawn_background_review", return_value=None):
    ```
  - `website/docs/developer-guide/context-compression-and-caching.md`

    ```diff
    @@ -86,6 +86,7 @@ compression:
       protect_last_n: 20         # Minimum protected tail messages (default: 20)
       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
     
     # Summarization model/provider configured under auxiliary:
     auxiliary:
    ```

    ```diff
    @@ -105,6 +106,7 @@ auxiliary:
     | `protect_first_n` | `3` | (hardcoded) | System prompt + first exchange always preserved |
     | `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 gpt-5.5 threshold autoraise
     
    ```

    ```diff
    @@ -131,6 +133,28 @@ To keep the 85% autoraise but hide only the one-time notice:
     hermes config set compression.codex_gpt55_autoraise_notice false
     ```
     
    +### Codex app-server thread compaction
    +
    +Codex app-server sessions (`api_mode: codex_app_server` — the codex CLI/agent
    +runtime) are different from every other route: the codex agent owns the backing
    +thread context, so Hermes' auxiliary summarizer cannot shrink it — rewriting the
    +local transcript mirror leaves the real thread growing unbounded until a hard
    +context reset. For this runtime, compaction goes through the app-server's own
    +mechanism instead:
    +
    +- Manual compaction (`/compress`) asks the app-server to compact the thread
    +  (`thread/compact/start`) and waits for the compaction turn to complete.
    +- Automatic compaction is controlled by `compression.codex_app_server_auto`:
    +  the default `native` lets the app-server decide when to compact and Hermes
    +  records the resulting compaction events (compression counters, session
    +  events). Set `hermes` to let Hermes' compression threshold initiate
    +  app-server compaction, or `off` to disable Hermes-initiated automatic
    +  compaction entirely (codex may still compact natively).
    +
    +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.
    +
     ### Computed Values (for a 200K context model at defaults)
     
     ```
    ```
- **#54633: feat(agent): add Codex-native compaction paths**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `87b65e24a`, `#60114`, `#36801`

## Fix status

- Merged to main in PR #60114 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-07`.
- Subsystem: `agent`
