Files
hermes-agent/hermes_cli/subcommands/peer.py
T
Teknium 189de16280 fix(peer): live-verified cross-gateway bot DMs — session shape + virtual-model replay
Two bugs found by a REAL two-gateway live test (two isolated HERMES_HOMEs,
bravo running the api_server platform, alpha's agent autonomously running
`hermes peer dm` from its Bot Chat protocol; reply relayed correctly and
persisted in bravo's canonical Bot Chat):

1. peer dm parsed the session-create response flat, but api_server wraps
   the row: {"object": "hermes.session", "session": {...}} — every first DM
   to a fresh peer failed with "Peer did not return a session id" (and the
   orphaned Bot Chat then 400'd retries with duplicate-title). Parse the
   wrapped shape; the test fake now mirrors the real response shape so this
   class can't pass green again.

2. api_server: a session created with no model persists the advertised
   virtual model ("hermes-agent") on the row; session chat then replayed it
   as a REAL model id and the provider 400'd ("hermes-agent is not a valid
   model ID"). _request_agent_overrides already filters the virtual model
   for per-request bodies — apply the same filter to the stored session
   model at both chat sites (sync + stream), so it means "gateway default"
   exactly like the request-body path.

Live E2E transcript (bravo's Bot Chat, via /api/sessions/{id}/messages):
  user:      Message from 🤖 alpha (@alpha): What is your callsign?
  assistant: CALLSIGN-BRAVO-7
peer cmd unit suite 10/10 with the corrected fake.
2026-08-17 16:34:22 -07:00

316 lines
12 KiB
Python

"""``hermes peer`` — bot-to-bot DMs across machines/gateways.
A *peer* is another Hermes gateway (any machine: homelab, Spark, Hermes
Cloud) running the ``api_server`` platform. Registering it here gives every
bot on THIS machine a transport to message bots on THAT machine:
hermes peer add spark --url http://spark.lan:8377 --key <API_SERVER_KEY>
hermes peer dm spark "Message from 🤖 dixie (@dixie): disk status?"
hermes peer dm spark/researcher "..." # named profile (multiplexed peer)
``dm`` resolves the remote agent's canonical "Bot Chat" session (by title,
creating it when missing), runs ONE synchronous agent turn over the peer's
existing ``POST /api/sessions/{id}/chat`` endpoint, and prints the reply on
stdout — the exact cross-machine twin of the local
``hermes -p <bot> chat --in ~ -c "Bot Chat" ...`` bot-messaging command, so
the Bot Mode protocol composes over it unchanged.
Design notes:
- No new server surface: the peer's stock api_server is the transport.
- Peer labels/URLs live in config.yaml (``bot_peers``); the peer's
API_SERVER_KEY is a credential and lives in ``~/.hermes/.env`` as
``HERMES_PEER_<NAME>_KEY``.
- Named-profile targets use the peer's ``/p/<profile>/`` multiplex mirror;
the bare target is the peer gateway's own (launch) profile.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
BOT_CHAT_TITLE = "Bot Chat"
_PEER_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_PROFILE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
# One synchronous agent turn can legitimately take minutes.
DM_TIMEOUT_S = 600
LIST_TIMEOUT_S = 30
def _peer_key_env(name: str) -> str:
return f"HERMES_PEER_{name.upper().replace('-', '_')}_KEY"
def _load_peers() -> dict:
from hermes_cli.config import load_config
cfg = load_config() or {}
peers = cfg.get("bot_peers")
return peers if isinstance(peers, dict) else {}
def _save_peers(peers: dict) -> None:
from hermes_cli.config import load_config, save_config
cfg = load_config() or {}
cfg["bot_peers"] = peers
save_config(cfg)
def _peer_secret(name: str) -> str:
"""The peer's API key: profile-scoped secret store first, raw env fallback."""
env_name = _peer_key_env(name)
try:
from agent.secret_scope import get_secret
return (get_secret(env_name, "") or "").strip()
except Exception:
import os
return (os.environ.get(env_name) or "").strip()
def _request(url: str, key: str, *, method: str = "GET", body: dict | None = None, timeout: int = LIST_TIMEOUT_S) -> dict:
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"User-Agent": "hermes-peer-dm",
},
)
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 — user-registered peer URL
payload = resp.read().decode("utf-8", "replace")
try:
parsed = json.loads(payload)
except ValueError as exc:
raise RuntimeError(f"Peer returned non-JSON response: {payload[:200]}") from exc
if not isinstance(parsed, dict):
raise RuntimeError("Peer returned a non-object JSON response")
return parsed
def _base_url(peer: dict, profile: str | None) -> str:
url = str(peer.get("url") or "").rstrip("/")
if profile:
# Multiplex mirror: same handlers, scoped to the named profile.
return f"{url}/p/{urllib.parse.quote(profile, safe='')}"
return url
def _find_bot_chat(base: str, key: str) -> str | None:
"""The remote canonical Bot Chat's session id, or None."""
listing = _request(f"{base}/api/sessions?limit=200", key)
for session in listing.get("data") or []:
if isinstance(session, dict) and (session.get("title") or "").strip() == BOT_CHAT_TITLE:
return str(session.get("id") or "") or None
return None
def _ensure_bot_chat(base: str, key: str) -> str:
existing = _find_bot_chat(base, key)
if existing:
return existing
created = _request(
f"{base}/api/sessions",
key,
method="POST",
body={"title": BOT_CHAT_TITLE, "source": "bot_peer_dm"},
)
# Real api_server wraps the row: {"object": "hermes.session", "session": {...}}.
session = created.get("session") if isinstance(created.get("session"), dict) else created
session_id = str(session.get("id") or session.get("session_id") or "")
if not session_id:
raise RuntimeError("Peer did not return a session id for the new Bot Chat")
return session_id
def _parse_target(target: str) -> tuple[str, str | None]:
"""``<peer>`` or ``<peer>/<profile>`` → (peer, profile|None)."""
raw = (target or "").strip()
peer, _, profile = raw.partition("/")
peer = peer.strip()
profile = profile.strip() or None
if not peer:
raise ValueError("Peer name required (hermes peer dm <peer>[/<agent>] ...)")
if profile and not _PROFILE_RE.match(profile):
raise ValueError(f"Invalid agent/profile name: {profile!r}")
return peer, profile
def _http_error_detail(exc: urllib.error.HTTPError) -> str:
try:
body = exc.read().decode("utf-8", "replace")
parsed = json.loads(body)
message = parsed.get("error", {}).get("message") if isinstance(parsed, dict) else None
return message or body[:200]
except Exception:
return str(exc)
def cmd_peer(args) -> int:
action = getattr(args, "peer_action", None)
if action in ("add", "set"):
name = (args.name or "").strip().lower()
if not _PEER_NAME_RE.match(name):
print(f"Invalid peer name: {name!r} (lowercase, digits, -, _; max 64)", file=sys.stderr)
return 2
url = (args.url or "").strip()
if not url.lower().startswith(("http://", "https://")):
print("Peer --url must be an http(s) gateway base URL, e.g. http://spark.lan:8377", file=sys.stderr)
return 2
peers = _load_peers()
peers[name] = {"url": url.rstrip("/"), **({"note": args.note.strip()} if getattr(args, "note", "") else {})}
_save_peers(peers)
key = (getattr(args, "key", "") or "").strip()
if key:
from hermes_cli.config import save_env_value
save_env_value(_peer_key_env(name), key)
print(f"Peer '{name}' saved ({url}) — key stored as {_peer_key_env(name)} in ~/.hermes/.env")
else:
print(
f"Peer '{name}' saved ({url}). No key given — set the peer's API_SERVER_KEY with:\n"
f" hermes peer add {name} --url {url} --key <key>\n"
f" (or add {_peer_key_env(name)}=<key> to ~/.hermes/.env)"
)
return 0
if action in ("remove", "rm"):
name = (args.name or "").strip().lower()
peers = _load_peers()
if name not in peers:
print(f"No peer named '{name}'.", file=sys.stderr)
return 1
peers.pop(name)
_save_peers(peers)
print(f"Peer '{name}' removed (its {_peer_key_env(name)} entry in .env is kept; delete it manually if unused).")
return 0
if action in ("list", "ls", None):
peers = _load_peers()
if not peers:
print("No peers registered. Add one: hermes peer add <name> --url http://host:port --key <API_SERVER_KEY>")
return 0
for name in sorted(peers):
entry = peers[name] if isinstance(peers[name], dict) else {}
has_key = "key set" if _peer_secret(name) else f"NO KEY ({_peer_key_env(name)} unset)"
note = f" — {entry.get('note')}" if entry.get("note") else ""
print(f"{name}\t{entry.get('url', '?')}\t[{has_key}]{note}")
return 0
if action == "dm":
try:
peer_name, profile = _parse_target(args.target)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
peers = _load_peers()
peer = peers.get(peer_name)
if not isinstance(peer, dict) or not peer.get("url"):
print(f"No peer named '{peer_name}'. Run: hermes peer list", file=sys.stderr)
return 1
key = _peer_secret(peer_name)
if not key:
print(
f"No API key for peer '{peer_name}'. Set it: hermes peer add {peer_name} "
f"--url <url> --key <key> (or add {_peer_key_env(peer_name)}=<key> to ~/.hermes/.env)",
file=sys.stderr,
)
return 1
message = (args.message or "").strip()
if not message and not sys.stdin.isatty():
message = sys.stdin.read().strip()
if not message:
print("Message required (argument or stdin).", file=sys.stderr)
return 2
base = _base_url(peer, profile)
try:
session_id = _ensure_bot_chat(base, key)
result = _request(
f"{base}/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat",
key,
method="POST",
body={"message": message},
timeout=DM_TIMEOUT_S,
)
except urllib.error.HTTPError as exc:
print(f"Peer '{peer_name}' rejected the request (HTTP {exc.code}): {_http_error_detail(exc)}", file=sys.stderr)
return 1
except (urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc:
print(f"Could not reach peer '{peer_name}': {exc}", file=sys.stderr)
return 1
reply = ""
msg = result.get("message")
if isinstance(msg, dict):
reply = str(msg.get("content") or "")
if getattr(args, "json", False):
print(json.dumps({"peer": peer_name, "profile": profile, "session_id": result.get("session_id") or session_id, "reply": reply}))
else:
print(reply or "(no reply)")
return 0
print("Unknown peer action. See: hermes peer --help", file=sys.stderr)
return 2
def build_peer_parser(subparsers) -> None:
"""Attach the ``peer`` subcommand to ``subparsers``."""
parser = subparsers.add_parser(
"peer",
help="Bot-to-bot DMs across machines (peer Hermes gateways)",
description=(
"Register other Hermes gateways as peers and message their agents. "
"'hermes peer dm <peer>[/<agent>] \"...\"' delivers into the remote "
"agent's canonical Bot Chat over the peer's API server and prints "
"the reply — the cross-machine twin of 'hermes -p <bot> chat'. "
"The peer must run the api_server platform; its API_SERVER_KEY is "
"stored locally as a credential in ~/.hermes/.env."
),
epilog=(
"Examples:\n"
" hermes peer add spark --url http://spark.lan:8377 --key <API_SERVER_KEY>\n"
" hermes peer list\n"
' hermes peer dm spark "Message from 🤖 dixie (@dixie): disk status?"\n'
' hermes peer dm spark/researcher "..." # named profile on a multiplexed peer\n'
" hermes peer remove spark\n"
"\n"
"Exit codes: 0 ok, 1 delivery/peer error, 2 usage error."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
peer_sub = parser.add_subparsers(dest="peer_action")
add_p = peer_sub.add_parser("add", aliases=["set"], help="Register (or update) a peer gateway")
add_p.add_argument("name", help="Peer name (lowercase slug, e.g. spark, homelab)")
add_p.add_argument("--url", required=True, help="Peer gateway base URL, e.g. http://spark.lan:8377")
add_p.add_argument("--key", default="", help="The peer's API_SERVER_KEY (stored in ~/.hermes/.env)")
add_p.add_argument("--note", default="", help="Optional description")
peer_sub.add_parser("list", aliases=["ls"], help="List registered peers")
rm_p = peer_sub.add_parser("remove", aliases=["rm"], help="Remove a peer")
rm_p.add_argument("name", help="Peer name")
dm_p = peer_sub.add_parser(
"dm",
help="Message an agent on a peer gateway and print its reply",
)
dm_p.add_argument("target", help="<peer> or <peer>/<agent> (named profile on a multiplexed peer)")
dm_p.add_argument("message", nargs="?", default=None, help="Message text (or stdin)")
dm_p.add_argument("--json", action="store_true", default=False, help="Emit a JSON result")
parser.set_defaults(func=cmd_peer)