feat(agent): add Codex-native compaction paths

This commit is contained in:
hmirin 2026-06-28 01:35:40 +09:00 committed by Teknium
parent 8fc1cb754b
commit d1c8c03416
14 changed files with 869 additions and 2 deletions

View file

@ -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).

View file

@ -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

View file

@ -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,
*,

View file

@ -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.

View file

@ -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)",

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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 ----

View file

@ -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()

View file

@ -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"]
)

View file

@ -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",
},
)
]

View file

@ -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):

View file

@ -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)
```