Hermes-agent
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""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, []
|
||||
# Accept human-friendly synonyms
|
||||
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: # pragma: no cover
|
||||
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)
|
||||
|
||||
# Cache the codex binary check for this apply() call. Subprocess spawn
|
||||
# is cheap (~50ms for `codex --version`), but we'd otherwise call it up
|
||||
# to 3 times in the enable path (read-only/state, gate, success message).
|
||||
# None = not yet checked; (bool, str) = result.
|
||||
_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
|
||||
|
||||
# Read-only call: just report state
|
||||
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,
|
||||
)
|
||||
|
||||
# No change requested
|
||||
if new_value == current:
|
||||
return CodexRuntimeStatus(
|
||||
success=True,
|
||||
new_value=current,
|
||||
old_value=current,
|
||||
message=f"openai_runtime already set to {current}",
|
||||
)
|
||||
|
||||
# If switching ON, verify codex CLI is installed before persisting —
|
||||
# an opt-in toggle that silently fails on the first turn is the
|
||||
# worst possible UX. Block here with a clear install hint.
|
||||
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}")
|
||||
# Auto-migrate Hermes' MCP servers + Codex's installed curated
|
||||
# plugins into ~/.codex/config.toml so the spawned codex subprocess
|
||||
# sees the same tool surface AND can call back into Hermes for
|
||||
# browser/web/delegate_task/vision/memory tools (#7 fix).
|
||||
# Failures are non-fatal — the runtime change still proceeds.
|
||||
try:
|
||||
from hermes_cli.codex_runtime_plugin_migration import migrate
|
||||
mig_report = migrate(config)
|
||||
# Tools/MCP servers (excluding the hermes-tools callback,
|
||||
# which is internal plumbing — surface separately).
|
||||
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)}"
|
||||
)
|
||||
# Native Codex plugin migration (Linear, GitHub, etc.)
|
||||
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}"
|
||||
)
|
||||
# Permissions + Hermes tool callback are always-on production
|
||||
# bits the user benefits from knowing about.
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user