mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
71e323111e
Translates the user's mcp_servers config from ~/.hermes/config.yaml into
the TOML format codex's MCP client expects. Wired into the
/codex-runtime codex_app_server enable path so users get their MCP tool
surface in the spawned subprocess automatically.
The migration runs on every enable. Failures are non-fatal — the runtime
change still proceeds and the user gets a warning so they can fix the
codex config manually.
What translates (mapping verified against codex-rs/core/src/config/edit.rs):
Hermes mcp_servers.<n>.command/args/env → codex stdio transport
Hermes mcp_servers.<n>.url/headers → codex streamable_http transport
Hermes mcp_servers.<n>.timeout → codex tool_timeout_sec
Hermes mcp_servers.<n>.connect_timeout → codex startup_timeout_sec
Hermes mcp_servers.<n>.cwd → codex stdio cwd
Hermes mcp_servers.<n>.enabled: false → codex enabled = false
What does NOT translate (warned + skipped per server):
Hermes-specific keys (sampling, etc.) — codex's MCP client has no
equivalent. Listed in the per-server skipped[] field of the report.
What's NOT migrated (intentional):
AGENTS.md — codex respects this file natively in its cwd. Hermes' own
AGENTS.md (project-level) is already in the worktree, so codex picks
it up without translation. No code needed.
Idempotency design:
All managed content lives between a 'managed by hermes-agent' marker
and the next non-mcp_servers section header. _strip_existing_managed_block
removes the prior managed region cleanly, preserving any user-added
codex config (model, providers.openai, sandbox profiles, etc.) above
or below.
Files added:
hermes_cli/codex_runtime_plugin_migration.py — pure-Python migration
helper. Public API: migrate(hermes_config, codex_home=None,
dry_run=False) returns MigrationReport with .migrated/.errors/
.skipped_keys_per_server. No external TOML dependency — minimal
formatter handles strings/numbers/booleans/lists/inline-tables.
tests/hermes_cli/test_codex_runtime_plugin_migration.py — 39 tests
covering:
- per-server translation (12): stdio/http/sse, cwd, timeouts,
enabled flag, command+url precedence, sampling drop, unknown keys
- TOML formatter (8): types, escaping, inline tables, error case
- existing-block stripping (4): no marker, alone, with user content
above, with user content below
- end-to-end migrate() (8): empty, dry-run, round-trip, idempotent
re-run, preserves user config, error reporting, invalid input,
summary formatting
Files changed:
hermes_cli/codex_runtime_switch.py — apply() now calls migrate() in
the codex_app_server enable branch. Migration failure logs a warning
in the result message but does NOT fail the runtime change. Disable
path (auto) explicitly skips migration.
tests/hermes_cli/test_codex_runtime_switch.py — 3 new tests:
test_enable_triggers_mcp_migration, test_disable_does_not_trigger_migration,
test_migration_failure_does_not_block_enable.
All 325 feature tests green:
- tests/agent/transports/: 249 (incl. 67 new)
- tests/run_agent/test_codex_app_server_integration.py: 9
- tests/hermes_cli/test_codex_runtime_switch.py: 28 (3 new)
- tests/hermes_cli/test_codex_runtime_plugin_migration.py: 39 (new)
225 lines
7.8 KiB
Python
225 lines
7.8 KiB
Python
"""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)
|
|
|
|
# Read-only call: just report state
|
|
if new_value is None:
|
|
ok, ver = check_codex_binary_ok()
|
|
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_codex_binary_ok()
|
|
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_codex_binary_ok()
|
|
if ok:
|
|
msg_lines.append(f"codex CLI: {ver}")
|
|
# Auto-migrate Hermes' MCP servers into ~/.codex/config.toml so
|
|
# the spawned codex subprocess sees the same tool surface. Failures
|
|
# here are non-fatal — the runtime change still proceeds.
|
|
try:
|
|
from hermes_cli.codex_runtime_plugin_migration import migrate
|
|
mig_report = migrate(config)
|
|
if mig_report.migrated:
|
|
msg_lines.append(
|
|
f"Migrated {len(mig_report.migrated)} MCP server(s) "
|
|
f"to {mig_report.target_path}"
|
|
)
|
|
elif mig_report.target_path:
|
|
msg_lines.append(
|
|
f"No MCP servers to migrate "
|
|
f"(wrote placeholder to {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)."
|
|
)
|
|
msg_lines.append(
|
|
"Note: subagents (delegate_task) are unavailable on this "
|
|
"runtime. Use `/codex-runtime auto` to switch back."
|
|
)
|
|
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,
|
|
)
|