| """Shared logic for the /codex-runtime slash command.
|
|
|
| Toggles `model.openai_runtime` between "auto" (= chat_completions, Hermes'
|
| default) and "codex_app_server" (= hand turns to a codex subprocess).
|
|
|
| Both CLI (cli.py) and gateway (gateway/run.py) call into this module so the
|
| behavior stays identical across surfaces.
|
|
|
| The actual runtime resolution happens in hermes_cli.runtime_provider's
|
| _maybe_apply_codex_app_server_runtime() helper, which reads the persisted
|
| config value. This module just persists the value and reports the change.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import logging
|
| from dataclasses import dataclass
|
| from typing import Optional
|
|
|
| logger = logging.getLogger(__name__)
|
|
|
|
|
| VALID_RUNTIMES = ("auto", "codex_app_server")
|
|
|
|
|
| @dataclass
|
| class CodexRuntimeStatus:
|
| """Result of a /codex-runtime invocation. Callers render this however
|
| suits their surface (CLI uses Rich panels, gateway sends a text message)."""
|
|
|
| success: bool
|
| new_value: Optional[str] = None
|
| old_value: Optional[str] = None
|
| message: str = ""
|
| requires_new_session: bool = False
|
| codex_binary_ok: bool = True
|
| codex_version: Optional[str] = None
|
|
|
|
|
| def parse_args(arg_string: str) -> tuple[Optional[str], list[str]]:
|
| """Parse the slash-command argument string. Returns (value, errors).
|
|
|
| No args β return current state (value=None)
|
| 'auto' / 'codex_app_server' / 'on' / 'off' β return that value
|
| anything else β error
|
| """
|
| raw = (arg_string or "").strip().lower()
|
| if not raw:
|
| return None, []
|
|
|
| if raw in {"on", "codex", "enable"}:
|
| return "codex_app_server", []
|
| if raw in {"off", "default", "disable", "hermes"}:
|
| return "auto", []
|
| if raw in VALID_RUNTIMES:
|
| return raw, []
|
| return None, [
|
| f"Unknown runtime {raw!r}. Use one of: auto, codex_app_server, on, off"
|
| ]
|
|
|
|
|
| def get_current_runtime(config: dict) -> str:
|
| """Read the current `model.openai_runtime` value from a config dict.
|
| Returns 'auto' for unset / empty / unrecognized values."""
|
| if not isinstance(config, dict):
|
| return "auto"
|
| model_cfg = config.get("model") or {}
|
| if not isinstance(model_cfg, dict):
|
| return "auto"
|
| value = str(model_cfg.get("openai_runtime") or "").strip().lower()
|
| if value in VALID_RUNTIMES:
|
| return value
|
| return "auto"
|
|
|
|
|
| def set_runtime(config: dict, new_value: str) -> str:
|
| """Mutate the config dict in place to persist the new runtime value.
|
| Returns the previous value for callers that want to report a delta."""
|
| if new_value not in VALID_RUNTIMES:
|
| raise ValueError(
|
| f"invalid runtime {new_value!r}; must be one of {VALID_RUNTIMES}"
|
| )
|
| old = get_current_runtime(config)
|
| if not isinstance(config.get("model"), dict):
|
| config["model"] = {}
|
| config["model"]["openai_runtime"] = new_value
|
| return old
|
|
|
|
|
| def check_codex_binary_ok() -> tuple[bool, Optional[str]]:
|
| """Best-effort verification that codex CLI is installed at acceptable
|
| version. Returns (ok, version_or_message)."""
|
| try:
|
| from agent.transports.codex_app_server import check_codex_binary
|
|
|
| return check_codex_binary()
|
| except Exception as exc:
|
| return False, f"codex check failed: {exc}"
|
|
|
|
|
| def apply(
|
| config: dict,
|
| new_value: Optional[str],
|
| *,
|
| persist_callback=None,
|
| ) -> CodexRuntimeStatus:
|
| """Top-level entry point used by both CLI and gateway handlers.
|
|
|
| Args:
|
| config: in-memory config dict (will be mutated when new_value is set)
|
| new_value: desired runtime; None means "show current state only"
|
| persist_callback: optional callable taking the mutated config dict
|
| and persisting it to disk. Skipped when None (used by tests).
|
|
|
| Returns: CodexRuntimeStatus describing the outcome.
|
| """
|
| current = get_current_runtime(config)
|
|
|
|
|
|
|
|
|
|
|
| _binary_check: Optional[tuple[bool, Optional[str]]] = None
|
|
|
| def _check_binary_cached() -> tuple[bool, Optional[str]]:
|
| nonlocal _binary_check
|
| if _binary_check is None:
|
| _binary_check = check_codex_binary_ok()
|
| return _binary_check
|
|
|
|
|
| if new_value is None:
|
| ok, ver = _check_binary_cached()
|
| msg = (
|
| f"openai_runtime: {current}\n"
|
| f"codex CLI: {'OK ' + ver if ok else 'not available β ' + (ver or 'install with `npm i -g @openai/codex`')}"
|
| )
|
| return CodexRuntimeStatus(
|
| success=True,
|
| new_value=current,
|
| old_value=current,
|
| message=msg,
|
| codex_binary_ok=ok,
|
| codex_version=ver if ok else None,
|
| )
|
|
|
|
|
| if new_value == current:
|
| return CodexRuntimeStatus(
|
| success=True,
|
| new_value=current,
|
| old_value=current,
|
| message=f"openai_runtime already set to {current}",
|
| )
|
|
|
|
|
|
|
|
|
| if new_value == "codex_app_server":
|
| ok, ver_or_msg = _check_binary_cached()
|
| if not ok:
|
| return CodexRuntimeStatus(
|
| success=False,
|
| new_value=None,
|
| old_value=current,
|
| message=(
|
| "Cannot enable codex_app_server runtime: "
|
| f"{ver_or_msg or 'codex CLI not available'}\n"
|
| "Install with: npm i -g @openai/codex"
|
| ),
|
| codex_binary_ok=False,
|
| codex_version=None,
|
| )
|
|
|
| set_runtime(config, new_value)
|
| if persist_callback is not None:
|
| try:
|
| persist_callback(config)
|
| except Exception as exc:
|
| logger.exception("failed to persist openai_runtime change")
|
| return CodexRuntimeStatus(
|
| success=False,
|
| new_value=new_value,
|
| old_value=current,
|
| message=f"updated config in memory but persist failed: {exc}",
|
| )
|
|
|
| msg_lines = [
|
| f"openai_runtime: {current} β {new_value}",
|
| ]
|
| if new_value == "codex_app_server":
|
| ok, ver = _check_binary_cached()
|
| if ok:
|
| msg_lines.append(f"codex CLI: {ver}")
|
|
|
|
|
|
|
|
|
|
|
| try:
|
| from hermes_cli.codex_runtime_plugin_migration import migrate
|
| mig_report = migrate(config)
|
|
|
|
|
| user_servers = [
|
| s for s in mig_report.migrated if s != "hermes-tools"
|
| ]
|
| if user_servers:
|
| msg_lines.append(
|
| f"Migrated {len(user_servers)} MCP server(s): "
|
| f"{', '.join(user_servers)}"
|
| )
|
|
|
| if mig_report.migrated_plugins:
|
| msg_lines.append(
|
| f"Migrated {len(mig_report.migrated_plugins)} native "
|
| f"Codex plugin(s): {', '.join(mig_report.migrated_plugins)}"
|
| )
|
| elif mig_report.plugin_query_error:
|
| msg_lines.append(
|
| f"Codex plugin discovery skipped: "
|
| f"{mig_report.plugin_query_error}"
|
| )
|
|
|
|
|
| if mig_report.wrote_permissions_default:
|
| msg_lines.append(
|
| f"Default sandbox: {mig_report.wrote_permissions_default} "
|
| f"(no approval prompt on every write)"
|
| )
|
| if "hermes-tools" in mig_report.migrated:
|
| msg_lines.append(
|
| "Hermes tool callback registered: codex can now use "
|
| "web_search, web_extract, browser_*, vision_analyze, "
|
| "image_generate, skill_view, skills_list, text_to_speech, "
|
| "kanban_* (worker + orchestrator) via MCP."
|
| )
|
| msg_lines.append(
|
| " (delegate_task, memory, session_search, todo run "
|
| "only on the default Hermes runtime β they need the "
|
| "agent loop context.)"
|
| )
|
| msg_lines.append(f" (config: {mig_report.target_path})")
|
| for err in mig_report.errors:
|
| msg_lines.append(f"β MCP migration: {err}")
|
| except Exception as exc:
|
| msg_lines.append(f"β MCP migration skipped: {exc}")
|
| msg_lines.append(
|
| "OpenAI/Codex turns now run through `codex app-server` "
|
| "(terminal/file ops/patching inside Codex; "
|
| "Hermes tools available via MCP callback)."
|
| )
|
| msg_lines.append(
|
| "Effective on next session β current cached agent keeps "
|
| "the prior runtime to preserve prompt cache."
|
| )
|
| else:
|
| msg_lines.append("OpenAI/Codex turns will use the default Hermes runtime.")
|
| msg_lines.append("Effective on next session.")
|
| return CodexRuntimeStatus(
|
| success=True,
|
| new_value=new_value,
|
| old_value=current,
|
| message="\n".join(msg_lines),
|
| requires_new_session=True,
|
| )
|
|
|