mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(agent): add Codex-native compaction paths
This commit is contained in:
parent
8fc1cb754b
commit
d1c8c03416
14 changed files with 869 additions and 2 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
)
|
||||
|
|
|
|||
196
tests/run_agent/test_codex_app_server_compaction.py
Normal file
196
tests/run_agent/test_codex_app_server_compaction.py
Normal 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",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue