NousResearch/hermes-agent

Codex compaction API integration for context compression

Cluster 33130 · agent. Data as of

Nothing open on GitHub. Fix merged in #60114 on 7 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #60114. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #60114 feat(compression): route Codex app-server compaction through thread/compact/start (#36801)

    merged to main

    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

    Show the diff
    • agent/agent_init.py
      @@ -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
      @@ -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
      @@ -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,
           *,
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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
      @@ -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:
      @@ -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
      @@ -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
      @@ -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
      @@ -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).
      @@ -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
      @@ -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
      @@ -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
      @@ -162,6 +162,7 @@
           "[email protected]": "coygeek",  # PR #37951 salvage (fail closed when provider env blocklist import fails; #37950)
           "[email protected]": "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)
      +    "[email protected]": "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
      @@ -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
      @@ -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)."""
      @@ -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
      @@ -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
      @@ -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
      @@ -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."""
      @@ -61,6 +61,7 @@ def _make_codex_agent():
               quiet_mode=True,
               skip_context_files=True,
               skip_memory=True,
      +        **kwargs,
           )
       
       
      @@ -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
      @@ -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:
      @@ -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
       
      @@ -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)
       
       ```

1 PR closed without merging

From the close comments: merged via another change 1

  • #54633 feat(agent): add Codex-native compaction paths · merged via another change → 87b65e2, #60114, #36801
Closed issues · 1
  • Closed#33130[Feature]: Use OpenAI Codex Compaction API instead of internal Compress for context compaction.
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#60114
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
7 Jul 2026

For your agent

The same facts, typed.