Skip to content
Closed
25 changes: 22 additions & 3 deletions agent/codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,25 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
# ids (msg_...) stay well under this cap and are worth keeping for
# prefix-cache hits. Drop only the oversized ones on replay.
_MAX_RESPONSES_ITEM_ID_LENGTH = 64
_MAX_RESPONSES_CALL_ID_LENGTH = 64


def _normalize_responses_call_id(value: str) -> str:
"""Return a stable Responses-compatible tool call id.

Codex app-server can mint MCP call ids longer than the Responses API's
64-character input limit. Background review replays those calls through
the Responses transport, so normalize only at this outbound boundary.
The stable suffix keeps matching function_call/function_call_output pairs
aligned without mutating the stored conversation or live app-server ids.
"""
call_id = value.strip()
if len(call_id) <= _MAX_RESPONSES_CALL_ID_LENGTH:
return call_id

digest = hashlib.sha256(call_id.encode("utf-8", errors="replace")).hexdigest()[:16]
prefix_length = _MAX_RESPONSES_CALL_ID_LENGTH - len(digest) - 1
return f"{call_id[:prefix_length]}_{digest}"


def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str:
Expand Down Expand Up @@ -633,7 +652,7 @@ def _preflight_codex_input_items(
normalized.append(
{
"type": "function_call",
"call_id": call_id.strip(),
"call_id": _normalize_responses_call_id(call_id),
"name": name.strip(),
"arguments": arguments,
}
Expand Down Expand Up @@ -674,7 +693,7 @@ def _preflight_codex_input_items(
normalized.append(
{
"type": "function_call_output",
"call_id": call_id.strip(),
"call_id": _normalize_responses_call_id(call_id),
"output": cleaned if cleaned else "",
}
)
Expand All @@ -685,7 +704,7 @@ def _preflight_codex_input_items(
normalized.append(
{
"type": "function_call_output",
"call_id": call_id.strip(),
"call_id": _normalize_responses_call_id(call_id),
"output": output,
}
)
Expand Down
51 changes: 50 additions & 1 deletion agent/codex_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,18 @@ def on_event(note: dict) -> None:
if not isinstance(note, dict):
return
method = note.get("method") or ""
if not isinstance(method, str) or not method:
return
touch = getattr(agent, "_touch_activity", None)
if callable(touch):
try:
touch(f"codex app-server event: {method}")
except Exception:
logger.debug(
"_touch_activity raised for codex app-server event %s",
method,
exc_info=True,
)
params = note.get("params") or {}
if not isinstance(params, dict):
params = {}
Expand Down Expand Up @@ -670,6 +682,22 @@ def run_codex_app_server_turn(
exc_info=True,
)

# Keep Hermes' configured MCPs scoped to the Codex app-server runtime
# while using the upstream full event bridge for progress/interim text.
codex_extra_args: list[str] = []
try:
from hermes_cli.config import load_config
from hermes_cli.codex_runtime_plugin_migration import (
build_runtime_mcp_enable_args,
)

codex_extra_args = build_runtime_mcp_enable_args(load_config())
except Exception:
logger.debug(
"codex app-server: failed to build scoped MCP overrides",
exc_info=True,
)

# Bridge codex JSON-RPC notifications (item/started, item/completed,
# item/agentMessage/delta, ...) into Hermes' gateway UI callbacks
# (tool_progress_callback, _fire_stream_delta,
Expand All @@ -679,6 +707,7 @@ def run_codex_app_server_turn(
# Supersedes the narrower item/started-only bridge from #38835.
agent._codex_session = CodexAppServerSession(
cwd=cwd,
extra_args=codex_extra_args,
approval_callback=approval_callback,
request_routing=_ServerRequestRouting(
auto_approve_exec=auto_approve_requests,
Expand All @@ -691,8 +720,28 @@ def run_codex_app_server_turn(
# standard run_conversation() flow (line ~11823) before the early
# return reaches us. Do NOT append again — that would duplicate.

touch = getattr(agent, "_touch_activity", None)
if callable(touch):
touch("codex app-server turn started")

# The app-server transport has its own wall-clock deadline, separate from
# the cron inactivity watchdog. Honor the same supported per-provider /
# per-model request timeout used by the other inference transports so a
# configured long-reasoning allowance is not silently capped at the
# transport's 600-second default.
from hermes_cli.timeouts import get_provider_request_timeout

turn_timeout = get_provider_request_timeout(
getattr(agent, "provider", ""),
getattr(agent, "model", None),
)
turn_kwargs = {"turn_timeout": turn_timeout} if turn_timeout is not None else {}

try:
turn = agent._codex_session.run_turn(user_input=user_message)
turn = agent._codex_session.run_turn(
user_input=user_message,
**turn_kwargs,
)
except Exception as exc:
logger.exception("codex app-server turn failed")
# Crash → unconditionally drop the session so the next turn
Expand Down
18 changes: 14 additions & 4 deletions agent/transports/codex_app_server_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
# enough to surface a config/provider/auth diagnostic.
_STDERR_TAIL_LINES = 12

# Reasoning-heavy Codex models can legitimately stay quiet for several minutes
# after a tool result. Keep this below the 10-minute outer turn deadline so a
# truly wedged app-server still retires early without interrupting healthy work.
_POST_TOOL_QUIET_TIMEOUT_SECONDS = 300.0


# Permission profile mapping mirrors the docstring in PR proposal:
# Hermes' tools.terminal.security_mode → Codex's permissions profile id.
Expand Down Expand Up @@ -204,6 +209,7 @@ def __init__(
cwd: Optional[str] = None,
codex_bin: str = "codex",
codex_home: Optional[str] = None,
extra_args: Optional[list[str]] = None,
permission_profile: Optional[str] = None,
approval_callback: Optional[Callable[..., str]] = None,
on_event: Optional[Callable[[dict], None]] = None,
Expand All @@ -213,6 +219,7 @@ def __init__(
self._cwd = cwd or os.getcwd()
self._codex_bin = codex_bin
self._codex_home = codex_home
self._extra_args = list(extra_args or [])
self._permission_profile = (
permission_profile or _HERMES_TO_CODEX_PERMISSION_PROFILE.get(
os.environ.get("HERMES_TERMINAL_SECURITY_MODE", "auto"),
Expand Down Expand Up @@ -245,7 +252,9 @@ def ensure_started(self) -> str:
return self._thread_id
if self._client is None:
self._client = self._client_factory(
codex_bin=self._codex_bin, codex_home=self._codex_home
codex_bin=self._codex_bin,
codex_home=self._codex_home,
extra_args=self._extra_args,
)
self._client.initialize(
client_name="hermes",
Expand Down Expand Up @@ -369,7 +378,7 @@ def run_turn(
*,
turn_timeout: float = 600.0,
notification_poll_timeout: float = 0.25,
post_tool_quiet_timeout: float = 90.0,
post_tool_quiet_timeout: float = _POST_TOOL_QUIET_TIMEOUT_SECONDS,
) -> TurnResult:
"""Send a user message and block until turn/completed, while
forwarding server-initiated approval requests and projecting items
Expand All @@ -378,8 +387,9 @@ def run_turn(
post_tool_quiet_timeout: if codex emits a tool completion and then
goes quiet for this many seconds without emitting another item or
`turn/completed`, fast-fail and mark the session for retirement.
Mirrors openclaw beta.8's post-tool completion watchdog (#81697)
so a wedged codex doesn't burn the full turn deadline.
Keeps a wedged codex from burning the full turn deadline while leaving
enough room for reasoning-heavy models to legitimately spend several
minutes processing a tool result before their next event.
"""
# Pre-create the result so startup failures (codex subprocess can't
# spawn, initialize handshake rejects, thread/start blows up) surface
Expand Down
38 changes: 23 additions & 15 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,18 @@ def _notify_single_query_session_finalize(cli, *, reason: str = "shutdown") -> N
def _finalize_single_query(cli) -> None:
"""Close one-shot CLI resources before releasing the active session lease."""
try:
agent = getattr(cli, "agent", None)
session_id = getattr(agent, "session_id", None) or getattr(
cli, "session_id", None
)
session_db = getattr(cli, "_session_db", None) or getattr(
agent, "_session_db", None
)
if session_db is not None and session_id:
try:
session_db.end_session(session_id, "agent_close")
except Exception:
pass
_notify_single_query_session_finalize(cli)
_run_cleanup(notify_session_finalize=False)
finally:
Expand Down Expand Up @@ -1862,17 +1874,13 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
def _run_state_db_auto_maintenance(session_db) -> None:
"""Call ``SessionDB.maybe_auto_prune_and_vacuum`` using current config.

Reads the ``sessions:`` section from config.yaml via
:func:`hermes_cli.config.load_config` (the authoritative loader that
deep-merges DEFAULT_CONFIG, so unmigrated configs still get default
values). Honours ``auto_prune`` / ``retention_days`` /
``vacuum_after_prune`` / ``min_interval_hours``, and delegates to the
DB. Never raises — maintenance must never block interactive startup.
Uses the profile-specific policy loaded by SessionDB and delegates the
maintenance operation to the database. Never raises — maintenance must
never block interactive startup.
"""
if session_db is None:
return
try:
from hermes_cli.config import load_config as _load_full_config
from hermes_constants import get_hermes_home as _get_hermes_home
_hermes_home_maint = _get_hermes_home()

Expand Down Expand Up @@ -1900,13 +1908,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:
except Exception as _finalize_exc:
logger.debug("Orphan compression finalize skipped: %s", _finalize_exc)

cfg = (_load_full_config().get("sessions") or {})
if not cfg.get("auto_prune", False):
return
session_db.maybe_auto_prune_and_vacuum(
retention_days=int(cfg.get("retention_days", 90)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
vacuum=bool(cfg.get("vacuum_after_prune", True)),
session_db.maybe_auto_maintenance(
sessions_dir=_hermes_home_maint / "sessions",
)
except Exception as exc:
Expand Down Expand Up @@ -4235,7 +4237,13 @@ def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) -
)
except Exception as exc:
logger.warning("Failed to claim active session slot: %s", exc)
return True
if stderr:
print("Hermes could not verify active-session ownership.", file=sys.stderr)
else:
self._console_print(
"[bold red]Hermes could not verify active-session ownership.[/]"
)
return False
if message:
if stderr:
print(message, file=sys.stderr)
Expand Down
17 changes: 17 additions & 0 deletions gateway/authz_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,23 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
# In multiplex gateways, route to the per-profile PairingStore so each
# profile's whitelist is isolated; falls back to the global store when
# the source has no profile or the profile isn't registered.
if getattr(source, "is_bot", False):
bot_allowlist_raw = (
os.getenv("DISCORD_ALLOWED_BOTS", "")
or os.getenv("DISCORD_ALLOWED_BOT_USERS", "")
if source.platform == Platform.DISCORD
else ""
)
if bot_allowlist_raw:
allowed_bot_ids = {
part.strip() for part in bot_allowlist_raw.split(",") if part.strip()
}
if "*" in allowed_bot_ids or user_id in allowed_bot_ids:
return True
allow_bots_var = platform_allow_bots_map.get(source.platform)
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
return True

platform_name = source.platform.value if source.platform else ""
pairing_store = self._pairing_store_for(source)
if pairing_store is not None and pairing_store.is_approved(platform_name, user_id):
Expand Down
4 changes: 4 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,10 @@ def _merge_platform_map(source_platforms: Any) -> None:
bridged["allowed_topics"] = platform_cfg["allowed_topics"]
if "free_response_channels" in platform_cfg:
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
if plat == Platform.DISCORD and "auto_thread_free_response" in platform_cfg:
bridged["auto_thread_free_response"] = platform_cfg["auto_thread_free_response"]
if plat == Platform.DISCORD and "self_message_channels" in platform_cfg:
bridged["self_message_channels"] = platform_cfg["self_message_channels"]
if "mention_patterns" in platform_cfg:
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if "exclusive_bot_mentions" in platform_cfg:
Expand Down
13 changes: 3 additions & 10 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3396,16 +3396,9 @@ def __init__(self, config: Optional[GatewayConfig] = None):
# but never raised.
if self._session_db is not None:
try:
from hermes_cli.config import load_config as _load_full_config
_sess_cfg = (_load_full_config().get("sessions") or {})
if _sess_cfg.get("auto_prune", False):
# Construction-time, before the loop serves traffic; sync DB is fine.
self._session_db._db.maybe_auto_prune_and_vacuum(
retention_days=int(_sess_cfg.get("retention_days", 90)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
sessions_dir=self.config.sessions_dir,
)
self._session_db._db.maybe_auto_maintenance(
sessions_dir=self.config.sessions_dir,
)
except Exception as exc:
logger.debug("state.db auto-maintenance skipped: %s", exc)

Expand Down
33 changes: 20 additions & 13 deletions hermes_cli/active_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import time
import uuid
from dataclasses import dataclass
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Optional

Expand Down Expand Up @@ -142,17 +143,21 @@ def __exit__(self, exc_type, exc, tb):
self._fh = None


def _read_entries(path: Path) -> list[dict[str, Any]]:
def _read_entries(path: Path, *, strict: bool = False) -> list[dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
return []
except Exception:
except Exception as exc:
if strict:
raise RuntimeError("active session registry is unreadable") from exc
logger.warning("Ignoring corrupt active session registry at %s", path)
return []
entries = data.get("entries") if isinstance(data, dict) else data
if not isinstance(entries, list):
if strict:
raise RuntimeError("active session registry has an invalid shape")
return []
return [entry for entry in entries if isinstance(entry, dict)]

Expand Down Expand Up @@ -240,19 +245,11 @@ def try_acquire_active_session(
) -> tuple[Optional[ActiveSessionLease], Optional[str]]:
"""Acquire an active-session slot.

Returns ``(lease, None)`` on success. When the cap is disabled, the lease is
a no-op object so callers can unconditionally call ``release()``.
Returns ``(lease, None)`` on success. A disabled cap still records ownership
so offline maintenance can distinguish live and abandoned sessions.
"""
max_sessions = resolve_max_concurrent_sessions(config)
lease_id = uuid.uuid4().hex
if max_sessions is None:
return ActiveSessionLease(
lease_id=lease_id,
session_id=session_id,
surface=surface,
enabled=False,
), None

now = time.time()
entry = {
"lease_id": lease_id,
Expand All @@ -276,7 +273,7 @@ def try_acquire_active_session(
if pruned:
logger.info("Pruned %d stale active session lease(s)", pruned)
active_count = len(entries)
if active_count >= max_sessions:
if max_sessions is not None and active_count >= max_sessions:
_write_entries(state_path, entries)
logger.info(
"Active session limit reached: active=%d max=%d surface=%s",
Expand Down Expand Up @@ -355,3 +352,13 @@ def active_session_registry_snapshot() -> list[dict[str, Any]]:
entries = _prune_dead(_read_entries(state_path))
_write_entries(state_path, entries)
return entries


@contextmanager
def locked_active_session_registry():
"""Hold the registry lock and yield live entries, failing closed on damage."""
state_path = _state_path()
with _FileLock(_lock_path()):
entries = _prune_dead(_read_entries(state_path, strict=True))
_write_entries(state_path, entries)
yield entries
Loading