Coverage for src/lilbee/cli/launchers/hermes.py: 100%
70 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""hermes launcher: registers lilbee in the user's real ~/.hermes, then runs hermes."""
3from __future__ import annotations
5import os
6import shutil
7from pathlib import Path
9import typer
10import yaml
12from lilbee.app.agent_configs.hermes import hermes_config
13from lilbee.app.agent_configs.merge import deep_merge, prune_lilbee
14from lilbee.cli.launchers import config_file
15from lilbee.cli.launchers.hermes_mcp import ensure_hermes_http_mcp
16from lilbee.cli.launchers.launcher import LILBEE_TOKEN_ENV_VAR, run_launcher
17from lilbee.cli.launchers.server import LOOPBACK, client_chat_ctx
18from lilbee.cli.launchers.skill_install import install_bundled_skill
19from lilbee.core.config import cfg
21_HERMES_INSTALL_HINT = (
22 "hermes binary not found on PATH. Install it from https://github.com/NousResearch/hermes-agent."
23)
24_TOKEN_REF = "${" + LILBEE_TOKEN_ENV_VAR + "}"
25_MCP_CONTAINER_KEY = "mcp_servers"
26_CONFIG_LABEL = "hermes config (config.yaml)"
27# hermes refuses to start against a model whose window is under this, so a smaller
28# one is worth naming here rather than leaving hermes to fail after the handoff.
29_HERMES_MIN_CTX = 64_000
30# hermes config keys gating its own auto-installs (security.allow_lazy_installs).
31_SECURITY_KEY = "security"
32_ALLOW_LAZY_INSTALLS_KEY = "allow_lazy_installs"
35def _hermes_home() -> Path:
36 """The user's real hermes home; never relocated, so memory and skills are shared."""
37 return Path.home() / ".hermes"
40def _hermes_config_path() -> Path:
41 return _hermes_home() / "config.yaml"
44def _hermes_env_path() -> Path:
45 return _hermes_home() / ".env"
48def _hermes_skill_dest() -> Path:
49 return _hermes_home() / "skills" / "lilbee-mcp"
52def _upsert_env_token(path: Path, token: str) -> None:
53 """Set ``LILBEE_TOKEN=<token>`` in the hermes ``.env`` (0600), preserving other lines."""
54 line = f"{LILBEE_TOKEN_ENV_VAR}={token}"
55 existing = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
56 kept = [ln for ln in existing if not ln.startswith(f"{LILBEE_TOKEN_ENV_VAR}=")]
57 # atomic_write_text creates the file 0600 and keeps that mode across the
58 # replace, so the token is never briefly readable and needs no chmod after.
59 config_file.atomic_write_text(path, "\n".join([*kept, line]) + "\n")
62def warn_hermes_ungrounded() -> None:
63 """Say plainly that hermes will run ungrounded when its MCP search did not connect.
65 Without the ``mcp`` extra hermes never calls ``lilbee_search`` and answers from
66 its own training, silently at exit 0, so the install hint alone is easy to miss.
67 """
68 typer.secho(
69 "Warning: hermes could not connect lilbee's search (MCP), so it will run "
70 "WITHOUT grounding -- it will not call lilbee_search and its answers come "
71 "from its own training, not your indexed docs. Install hermes's mcp extra "
72 "(shown above) and relaunch to ground it.",
73 err=True,
74 fg=typer.colors.YELLOW,
75 )
78def warn_if_below_hermes_minimum(chat_ctx: int | None) -> None:
79 """Tell the user up front when hermes will reject the window lilbee serves."""
80 if chat_ctx is None or chat_ctx >= _HERMES_MIN_CTX:
81 return
82 typer.secho(
83 f"Warning: hermes requires at least a {_HERMES_MIN_CTX:,}-token context and "
84 f"lilbee serves {chat_ctx:,}, so hermes will refuse to start. Chat with a "
85 "longer-context model, a smaller quantization, or a higher gpu_memory_fraction.",
86 err=True,
87 fg=typer.colors.YELLOW,
88 )
91class HermesLauncher:
92 """``Launcher`` implementation for hermes-agent."""
94 name = "hermes"
95 install_hint = _HERMES_INSTALL_HINT
97 def __init__(self, *, include_mcp: bool = True) -> None:
98 self._include_mcp = include_mcp
99 self._binary: str | None = None
101 def find_binary(self) -> str | None:
102 self._binary = shutil.which("hermes")
103 return self._binary
105 def prepare(
106 self, *, token: str, port: int, model_refs: list[str]
107 ) -> tuple[list[str], dict[str, str]]:
108 config = config_file.load_config_dict(
109 _hermes_config_path(),
110 parse=yaml.safe_load,
111 parse_error=yaml.YAMLError,
112 label=_CONFIG_LABEL,
113 )
114 chat_ctx = client_chat_ctx(port)
115 warn_if_below_hermes_minimum(chat_ctx)
116 fragment = hermes_config(
117 base_url=f"http://{LOOPBACK}:{port}",
118 api_key=_TOKEN_REF,
119 model_refs=model_refs,
120 default_ref=str(cfg.chat_model),
121 chat_ctx=chat_ctx,
122 include_mcp=self._include_mcp,
123 )
124 deep_merge(config, fragment)
125 if not self._include_mcp:
126 prune_lilbee(config, _MCP_CONTAINER_KEY)
127 config_file.atomic_write_text(
128 _hermes_config_path(), yaml.safe_dump(config, sort_keys=False)
129 )
130 _upsert_env_token(_hermes_env_path(), token)
131 if self._include_mcp:
132 install_bundled_skill(_hermes_skill_dest())
133 # hermes ships HTTP MCP behind the optional `mcp` extra; without it
134 # lilbee's MCP search shows "0 connected". Set it up before launch,
135 # honoring hermes's own auto-install security gate.
136 # _binary is non-None: run_launcher called find_binary() and gated on it.
137 if self._binary is not None:
138 allow_lazy = bool(
139 (config.get(_SECURITY_KEY) or {}).get(_ALLOW_LAZY_INSTALLS_KEY, True)
140 )
141 mcp_ready = ensure_hermes_http_mcp(
142 self._binary, allow_lazy_installs=allow_lazy, echo=typer.echo
143 )
144 # Requested but not connected: don't hand off a silently ungrounded hermes.
145 if not mcp_ready:
146 warn_hermes_ungrounded()
147 return ([], {**os.environ, LILBEE_TOKEN_ENV_VAR: token})
150def hermes_cmd(
151 mcp: bool | None = typer.Option(
152 None,
153 "--mcp/--no-mcp",
154 help="Register lilbee's MCP search tool into hermes. Defaults to the "
155 "agent_mcp_enabled config; --mcp/--no-mcp overrides it for this launch.",
156 ),
157) -> None:
158 """Launch hermes with lilbee registered as its model provider."""
159 include_mcp = cfg.agent_mcp_enabled if mcp is None else mcp
160 run_launcher(HermesLauncher(include_mcp=include_mcp))