diff --git a/agent/agent_init.py b/agent/agent_init.py index 32a06259441..495260d2188 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1570,6 +1570,16 @@ def init_agent( 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 init_agent( 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). diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 1cf48ec1705..2077d2fddca 100644 --- a/agent/codex_runtime.py +++ b/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 = approx_tokens 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 run_codex_app_server_turn( 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 diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 7e7a26dc2ed..843960cc281 100644 --- a/agent/conversation_compression.py +++ b/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=approx_tokens, + 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 compress_context( _release_lock() +def _compress_context_via_codex_app_server( + agent: Any, + messages: list, + system_message: Optional[str], + *, + approx_tokens: Optional[int] = 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=approx_tokens, + 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, *, diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 7292823766e..78af728711d 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -75,6 +75,7 @@ class TurnResult: token_usage_last: Optional[dict[str, Any]] = None token_usage_total: Optional[dict[str, 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 @@ class CodexAppServerSession: 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 @@ class CodexAppServerSession: 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 @@ class CodexAppServerSession: 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. diff --git a/agent/turn_context.py b/agent/turn_context.py index 89b00819c2c..4fd6ddff2c3 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -369,6 +369,20 @@ def build_turn_context( lambda _tokens: False, ) _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)", diff --git a/cli-config.yaml.example b/cli-config.yaml.example index f058705cfe2..8de33b80735 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -410,6 +410,21 @@ 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 + + # Codex-native compaction paths are opt-in (default: false) to preserve + # Hermes' existing auxiliary summarizer behavior for existing installs. + # When true, Codex OAuth uses Responses API compact and Codex app-server + # compaction uses the app-server thread compact API. + codex_native_compaction: false + + # Codex OAuth / Responses API compaction trigger (default: 0.85 = 85%). + # Used only when codex_native_compaction is true. The recent tail still uses + # protect_last_n below. + codex_responses_threshold: 0.85 # 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. @@ -422,6 +437,13 @@ compression: # compression of older turns. protect_last_n: 20 + # Codex app-server auto-compaction mode: + # Used only when codex_native_compaction is true. + # 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 diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 81d66e99eba..7f2945de5f8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1377,6 +1377,14 @@ DEFAULT_CONFIG = { # 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 diff --git a/scripts/release.py b/scripts/release.py index 32f7c577a27..b7895aa051b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -163,6 +163,7 @@ AUTHOR_MAP = { "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) "jmmaloney4@gmail.com": "jmmaloney4", # PR #25206 salvage (re-select credential pool on primary runtime restore; #25205) + "hmirin@users.noreply.github.com": "hmirin", "dale@dalenguyen.me": "dalenguyen", # PR #53678 salvage (strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env; #23473) "liruixinch@outlook.com": "HexLab98", # PR #53863 salvage (env-only proxy policy for auxiliary OpenAI clients on macOS; #53702) "blaryx@gmail.com": "Blaryxoff", # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396) diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index 0322fb08721..adc3470131e 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -445,6 +445,107 @@ class TestRunTurn: 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 ---- diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 73a4941db3f..708b2d3ac71 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -228,16 +228,24 @@ class TestExtractCacheBustingConfig: "compression": { "enabled": False, "threshold": 0.6, + "codex_gpt55_autoraise": False, + "codex_native_compaction": True, + "codex_responses_threshold": 0.85, "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.codex_native_compaction"] is True + assert out["compression.codex_responses_threshold"] == 0.85 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 +2124,3 @@ class TestCrossProcessInvalidationDefersCleanup: assert release_calls == [old_agent] runner._cleanup_agent_resources.assert_not_called() - diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index b777bf393ce..b580ca14471 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -1780,3 +1780,54 @@ class TestConfigNormalizationDoesNotOverwriteUserValues: def test_explicit_config_paths_ignore_empty_sections(self): assert _explicit_config_paths({"memory": {}, "display": {}}) == set() + + +class TestCodexNativeCompactionConfig: + """Codex-native compaction stays opt-in without mutating existing configs.""" + + def _write(self, tmp_path, body): + (tmp_path / "config.yaml").write_text(body, encoding="utf-8") + + def test_default_config_keeps_codex_native_compaction_opt_in(self): + assert DEFAULT_CONFIG["compression"]["codex_native_compaction"] is False + assert DEFAULT_CONFIG["compression"]["codex_gpt55_autoraise"] is True + + def test_migration_does_not_write_codex_native_compaction_default(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + self._write( + tmp_path, + "_config_version: 31\n" + "compression:\n" + " threshold: 0.5\n" + " codex_gpt55_autoraise: false\n", + ) + + result = migrate_config(interactive=False, quiet=True) + + raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert raw["compression"]["codex_gpt55_autoraise"] is False + assert "codex_native_compaction" not in raw["compression"] + assert raw["compression"]["threshold"] == 0.5 + assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] + assert ( + "compression.codex_native_compaction=false" + not in result["config_added"] + ) + + def test_preserves_existing_codex_native_compaction_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_native_compaction: true\n", + ) + + result = migrate_config(interactive=False, quiet=True) + + raw = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert raw["compression"]["codex_native_compaction"] is True + assert ( + "compression.codex_native_compaction=false" + not in result["config_added"] + ) diff --git a/tests/run_agent/test_codex_app_server_compaction.py b/tests/run_agent/test_codex_app_server_compaction.py new file mode 100644 index 00000000000..8f9cea51f81 --- /dev/null +++ b/tests/run_agent/test_codex_app_server_compaction.py @@ -0,0 +1,196 @@ +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", + codex_native_compaction=True, + ): + self.api_mode = "codex_app_server" + self.codex_native_compaction_enabled = codex_native_compaction + 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=100000, + 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=100000, + 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=100000, + ) + + 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=100000, + 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", + }, + ) + ] diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index f9d2dcf652d..df16807ca84 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -49,7 +49,7 @@ def fake_session(monkeypatch): ) -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 @@ class TestRunConversationCodexPath: 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): diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index d9f4f6aac05..aa817190c17 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/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) ```