Files
hermes-agent/tools/bot_relay.py

608 lines
24 KiB
Python

"""Bot Mode cross-connection relay — connections ARE the peer set.
Gateway-side half of the relay that lets agents on ANY Desktop-connected
gateway (local, remote URL, SSH, Hermes Cloud, docker) message agents on ANY
other, with ``message_agent`` as the one send path — connections ARE the peer
set. Plain file plumbing under ``<root>/bot_relay/`` — no network; the gateway
never holds another connection's credentials, the Desktop owns every socket and
does all cross-connection I/O:
- ``roster.json`` — union roster of agents on OTHER connections, pushed by the
Desktop (``bot_relay.roster.sync``); folded into the Bot Chat protocol section
and used to resolve cross-connection targets.
- ``outbox/`` — envelopes queued by ``message_agent``; the Desktop drains them
(``bot_relay.outbox.drain``) and delivers on the target connection.
- ``replies/`` — one JSON per envelope (``bot_relay.reply``); a background
waiter spawned at send time watches it so the reply wakes the sender via the
same completion-notification path local DMs use.
Public helpers never raise, except ``enqueue_envelope`` → ``EnvelopeRefusedError``
when the target is definitively offline (fail fast instead of queueing a DM
nobody will drain).
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import re
import shlex
import shutil
import sys
import tempfile
import time
import uuid
from pathlib import Path
from typing import Any, Iterator, Optional
from tools.bot_mode_probe import _default_home, _hermes_root
logger = logging.getLogger(__name__)
RELAY_DIR_NAME = "bot_relay"
ROSTER_FILE = "roster.json"
OUTBOX_DIR = "outbox"
CLAIMED_DIR = "claimed"
REPLIES_DIR = "replies"
LOCKS_DIR = "locks"
# Fallback wait budget for a queued delivery turn when config is unreadable
# (real knob: ``bot_mode.turn_wait_seconds``).
TURN_WAIT_SECONDS_FALLBACK = 120
# Waiter give-up budget. Cross-connection turns can be slow (remote model,
# cold gateway) — generous, but bounded.
REPLY_WAIT_SECONDS = 900
# Envelopes/replies older than this are stale artifacts (Desktop closed,
# connection died) and are swept opportunistically.
STALE_AFTER_SECONDS = 6 * 3600
# Fallback envelope TTL when config is unreachable — mirrors the
# ``bot_mode.envelope_ttl_seconds`` default. Older envelopes are refused at
# drain time with a 'queued_expired' error reply instead of delivered late.
DEFAULT_ENVELOPE_TTL_SECONDS = 900
# A roster older than this proves nothing about who is offline: the Desktop
# re-pushes roster.sync on connection-state changes, so only a recent roster
# is authoritative for the fail-fast check.
ROSTER_FRESH_SECONDS = 600
class EnvelopeRefusedError(RuntimeError):
"""``enqueue_envelope`` refused to queue — nothing was written to disk.
``reason`` is a stable machine code ('runtime_offline'); ``str(exc)`` is the
human text.
"""
def __init__(self, reason: str, message: str):
super().__init__(message)
self.reason = reason
# Profile names, handles and connection ids share one shape (also the local
# ``message_agent`` target grammar in ``tools/bot_mode_dm.py``).
_HANDLE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
# One turn in a profile's canonical Bot Chat: ``hermes -p <profile> *BOT_CHAT_TURN_ARGS``.
# ``-c "Bot Chat"`` must match ``bot_mode_probe.BOT_CHAT_TITLE``.
BOT_CHAT_TURN_ARGS = ("chat", "--in", "~", "-c", "Bot Chat", "--create-if-missing", "-Q")
def relay_root(root: Path | str) -> Path:
return Path(root) / RELAY_DIR_NAME
def _ensure_dirs(root: Path | str) -> Path:
base = relay_root(root)
for sub in (OUTBOX_DIR, CLAIMED_DIR, REPLIES_DIR):
(base / sub).mkdir(parents=True, exist_ok=True)
return base
def _atomic_write_json(target: Path, payload: Any, *, prefix: str, sort_keys: bool = False) -> None:
"""Write ``payload`` to ``target`` via tempfile + os.replace (readers never see
a partial file). The tempfile is removed if the write fails."""
fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix=prefix, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, sort_keys=sort_keys)
os.replace(tmp, target)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
# ── remote roster ────────────────────────────────────────────────────────────
def _normalize_roster_row(row: Any) -> Optional[dict]:
"""Validated, minimal roster row or None.
Rows come from the Desktop over RPC — treat as untrusted input. A row names
an agent on another connection: profile, taggable handle, owning connection
id/label, and optional title/description for the protocol section.
"""
if not isinstance(row, dict):
return None
profile = str(row.get("profile") or "").strip()
handle = str(row.get("handle") or "").strip().lstrip("@")
connection_id = str(row.get("connection_id") or "").strip()
if not profile or not connection_id:
return None
if not handle:
handle = "hermes" if profile == "default" else profile
if (
not _HANDLE_RE.match(handle)
or not _HANDLE_RE.match(profile)
or not _HANDLE_RE.match(connection_id)
):
return None
out = {
"profile": profile,
"handle": handle,
"connection_id": connection_id,
"connection_label": str(row.get("connection_label") or "").strip()[:80],
"title": str(row.get("title") or "").strip()[:120],
"description": " ".join(str(row.get("description") or "").split())[:160],
}
# Optional liveness flag, kept only when a real bool so absent stays
# distinguishable from false: absent == unknown == fail-open on enqueue.
if isinstance(row.get("online"), bool):
out["online"] = row["online"]
return out
def write_remote_roster(root: Path | str, rows: Any) -> int:
"""Atomically persist the Desktop-pushed remote roster. Returns count."""
base = _ensure_dirs(root)
by_key: dict[tuple[str, str], dict] = {}
for row in rows if isinstance(rows, list) else []:
norm = _normalize_roster_row(row)
if norm:
by_key.setdefault((norm["connection_id"], norm["profile"]), norm)
cleaned = [by_key[k] for k in sorted(by_key)]
payload = {"updated_at": int(time.time()), "agents": cleaned}
_atomic_write_json(base / ROSTER_FILE, payload, prefix=".roster-", sort_keys=True)
return len(cleaned)
def read_remote_roster(root: Path | str) -> list[dict]:
"""The current remote roster (possibly empty). Never raises."""
try:
raw = (relay_root(root) / ROSTER_FILE).read_text(encoding="utf-8")
data = json.loads(raw)
agents = data.get("agents") if isinstance(data, dict) else None
if not isinstance(agents, list):
return []
return [r for r in (_normalize_roster_row(a) for a in agents) if r]
except FileNotFoundError:
return []
except Exception:
logger.debug("bot_relay roster read failed", exc_info=True)
return []
def resolve_remote_target(raw_target: str, roster: list[dict]) -> Any:
"""Resolve ``raw_target`` against the remote roster.
Accepted forms:
- bare handle/profile (``moxie``) — must be unique across connections;
- ``<handle>@<connection-id>`` / ``<profile>@<connection-id>`` — exact.
Returns the matched row, the string ``"ambiguous"`` when a bare form
matches agents on several connections, or None for no match.
"""
want = str(raw_target or "").strip().lstrip("@")
if not want:
return None
conn: Optional[str] = None
if "@" in want:
want, _, conn = want.partition("@")
want = want.strip()
conn = conn.strip()
if not want or not conn:
return None
matches = [
row
for row in roster
if want.lower() in (row["handle"].lower(), row["profile"].lower())
and (not conn or row["connection_id"].lower() == conn.lower())
]
if not matches:
return None
return matches[0] if len(matches) == 1 else "ambiguous"
def remote_target_forms(roster: list[dict]) -> list[str]:
"""Human/agent-facing target strings: bare handle when unique across
connections, else ``handle@connection`` (mirrors ``resolve_remote_target``)."""
handles = [row["handle"].lower() for row in roster]
return [
f"{row['handle']}@{row['connection_id']}" if handles.count(h) > 1 else row["handle"]
for row, h in zip(roster, handles)
]
# ── outbox / replies ─────────────────────────────────────────────────────────
def _envelope_ttl_seconds() -> int:
"""Configured drain TTL (``bot_mode.envelope_ttl_seconds``), read per-drain
(tools/ must not import CLI config at import time); falls back to
``DEFAULT_ENVELOPE_TTL_SECONDS``. ``0`` (or negative) disables expiry."""
try:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly() or {}
val = (cfg.get("bot_mode") or {}).get("envelope_ttl_seconds")
if val is not None:
return int(val)
except Exception:
logger.debug("bot_relay TTL config read failed", exc_info=True)
return DEFAULT_ENVELOPE_TTL_SECONDS
def _target_liveness(root: Path | str, target: dict) -> Optional[bool]:
"""Tri-state liveness for ``target``: True / False / None (unknown).
'Definitively offline' = explicit ``online: false`` on the row, or the
target ABSENT from a *fresh* roster (the Desktop re-pushes the whole roster
on connection-state changes). A missing, unreadable, empty or stale roster
proves nothing → None, and callers fail open. Never raises.
"""
try:
roster_path = relay_root(root) / ROSTER_FILE
try:
age = time.time() - roster_path.stat().st_mtime
except OSError:
return None # no roster ever synced — unknown
if age > ROSTER_FRESH_SECONDS:
return None # stale view — unknown
roster = read_remote_roster(root)
if not roster:
return None # empty/corrupt roster — treat as unknown, fail open
key = (str(target.get("connection_id") or ""), str(target.get("profile") or ""))
for row in roster:
if (row["connection_id"], row["profile"]) == key:
online = row.get("online")
if online is False:
return False
return True if online is True else None
return False # fresh roster no longer lists the target — offline
except Exception:
logger.debug("bot_relay liveness check failed", exc_info=True)
return None
def enqueue_envelope(
root: Path | str,
*,
target: dict,
message: str,
sender_profile: str,
sender_handle: str,
) -> dict:
"""Queue a cross-connection DM for the Desktop relay. Returns envelope.
Raises ``EnvelopeRefusedError`` ('runtime_offline') without writing when the
target is definitively offline; unknown liveness enqueues (fail-open).
"""
if _target_liveness(root, target) is False:
label = (
f"@{target.get('handle') or target.get('profile') or '?'} on "
f"{target.get('connection_label') or target.get('connection_id') or '?'}"
)
raise EnvelopeRefusedError(
"runtime_offline",
f"{label} is offline right now — the message was NOT queued. "
"Try again once that machine reconnects to the Desktop.",
)
base = _ensure_dirs(root)
envelope = {
"id": uuid.uuid4().hex,
"created_at": int(time.time()),
"from_profile": sender_profile,
"from_handle": sender_handle,
"target_connection": target["connection_id"],
"target_profile": target["profile"],
"target_handle": target["handle"],
"message": message,
}
_atomic_write_json(base / OUTBOX_DIR / f"{envelope['id']}.json", envelope, prefix=".env-")
return envelope
def _expire_if_stale(root: Path | str, path: Path, ttl: float, now: float) -> bool:
"""True when the outbox envelope at ``path`` is older than ``ttl``; writes the
'queued_expired' error reply so the sender's waiter resolves (best effort —
an invalid id still counts as expired). Unreadable envelopes are left for
the claim attempt to deal with."""
try:
env = json.loads(path.read_text(encoding="utf-8"))
created = float(env.get("created_at") or path.stat().st_mtime)
except (OSError, ValueError):
return False
if now - created <= ttl:
return False
handle = str(env.get("target_handle") or "?")
conn = str(env.get("target_connection") or "?")
with contextlib.suppress(OSError, ValueError):
write_reply(
root,
str(env.get("id") or ""),
error=(
f"queued message to @{handle} on {conn} expired after "
f"{ttl}s waiting for the Desktop to drain it — it was "
"NOT delivered. Resend once the Desktop reconnects."
),
reason="queued_expired",
)
return True
def claim_pending_envelopes(root: Path | str) -> list[dict]:
"""Drain the outbox (rename → claimed/, so a second drain can't double-
deliver). Sweeps stale claimed/reply artifacts opportunistically.
Envelopes older than ``bot_mode.envelope_ttl_seconds`` are NOT delivered:
each gets a 'queued_expired' error reply (so the sender's waiter resolves)
and its outbox file is removed.
"""
base = _ensure_dirs(root)
_sweep_stale(base)
ttl = _envelope_ttl_seconds()
now = time.time()
out: list[dict] = []
for path in sorted((base / OUTBOX_DIR).glob("*.json")):
if ttl > 0 and _expire_if_stale(root, path, ttl, now):
with contextlib.suppress(OSError):
path.unlink()
continue
claimed = base / CLAIMED_DIR / path.name
try:
os.replace(path, claimed) # atomic claim
out.append(json.loads(claimed.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
return out
def write_reply(
root: Path | str, envelope_id: str, *, reply: str = "", error: str = "", reason: str = ""
) -> Path:
"""Persist the relayed reply (or delivery error) for the waiter.
``reason`` is an optional typed failure code (``tools.bot_failure_reasons``);
when omitted and ``error`` is non-empty it is classified from the text. The
waiter only surfaces the human ``error`` (plus the code as a tag).
"""
base = _ensure_dirs(root)
safe = str(envelope_id or "").strip()
if not re.match(r"^[0-9a-f]{32}$", safe):
raise ValueError(f"invalid envelope id: {envelope_id!r}")
err = str(error or "")
code = str(reason or "")
if not code and err:
from tools.bot_failure_reasons import classify_agent_error
code = classify_agent_error(err)
path = base / REPLIES_DIR / f"{safe}.json"
payload = {"id": safe, "at": int(time.time()), "reply": str(reply or ""), "error": err, "reason": code}
_atomic_write_json(path, payload, prefix=".rep-")
return path
def unlink_files_older_than(directory: Path, pattern: str, cutoff: float) -> int:
"""Remove regular files under ``directory`` matching ``pattern`` with mtime
before ``cutoff``; returns the count. Never raises (missing dir → 0)."""
removed = 0
try:
for path in directory.glob(pattern):
try:
if path.is_file() and path.stat().st_mtime < cutoff:
path.unlink()
removed += 1
except OSError:
continue
except OSError:
pass
return removed
def _sweep_stale(base: Path, *, now: float | None = None) -> int:
cutoff = (time.time() if now is None else now) - STALE_AFTER_SECONDS
return sum(
unlink_files_older_than(base / sub, "*.json", cutoff)
for sub in (CLAIMED_DIR, REPLIES_DIR, OUTBOX_DIR)
)
def cleanup_bot_relay_artifacts(max_age_hours: float | None = None) -> int:
"""Sweep stale relay artifacts (envelopes/replies hold DM plaintext).
``_sweep_stale`` otherwise runs only on Desktop drains — if the Desktop never
reconnects, plaintext would sit on disk forever. Same contract as the
``cleanup_*_cache`` helpers (hourly housekeeping). ``max_age_hours`` is
accepted for signature compatibility; ``STALE_AFTER_SECONDS`` governs.
"""
del max_age_hours
try:
base = relay_root(_hermes_root(Path(_default_home())))
if not base.is_dir():
return 0
return _sweep_stale(base)
except Exception:
logger.debug("bot_relay artifact sweep failed", exc_info=True)
return 0
# ── waiter (runs on the sender gateway via terminal background process) ─────
def waiter_command(root: Path | str, envelope: dict) -> str:
"""Shell command that blocks until the reply file appears, then prints it.
Spawned via ``terminal_tool(background=True, notify_on_complete=True)`` so
its stdout — the reply — arrives as the same completion notification local
DMs use. Stdlib-only; runs under the sender gateway's interpreter.
"""
reply_path = str(relay_root(root) / REPLIES_DIR / f"{envelope['id']}.json")
label = (
f"@{envelope.get('target_handle', '')} "
f"on {envelope.get('target_connection', '')}"
)
# !r keeps roster fields from breaking out of the generated python -c source.
# The r-prefix keeps Windows paths viable: the Windows execution layer folds
# repr's "\\" back to "\", turning "\U" into an invalid unicode escape; a
# raw literal parses the folded backslash literally. No-op on POSIX, and \'
# still cannot terminate a raw literal, so the injection defense holds.
code = (
"import json,os,sys,time\n"
f"p = r{reply_path!r}\n"
f"label = r{label!r}\n"
f"deadline = time.time() + {REPLY_WAIT_SECONDS}\n"
"while time.time() < deadline:\n"
" if os.path.exists(p):\n"
" d = json.load(open(p, encoding='utf-8'))\n"
" if d.get('error'):\n"
# Typed reason code rides ahead of the free text so the sender can
# branch on it without parsing provider prose.
" code = str(d.get('reason') or '').strip()\n"
" tag = ' [reason: ' + code + ']' if code else ''\n"
" print('Delivery to ' + label + ' failed' + tag + ': ' + d['error'])\n"
" sys.exit(1)\n"
" print('Reply from ' + label + ':')\n"
" print(d.get('reply') or '(empty reply)')\n"
" sys.exit(0)\n"
# 250ms cadence: stat is cheap and a longer sleep is pure dead air.
" time.sleep(0.25)\n"
f"print('No reply from ' + label + ' within {REPLY_WAIT_SECONDS}s. The message may "
"still be delivered when the Desktop reconnects; do not resend blindly.')\n"
"sys.exit(1)\n"
)
return f"{shlex.quote(sys.executable or 'python3')} -c {shlex.quote(code)}"
# ── delivery command (used by the deliver RPC on the TARGET gateway) ────────
def _hermes_cli() -> str:
"""Resolve the hermes CLI beside this gateway's own interpreter.
Service contexts (systemd, desktop launchers, non-login SSH) lack PATH, so a
bare "hermes" died with ENOENT; the venv sibling wins, then ``shutil.which``
(honors whatever PATH exists), then the bare name.
"""
exe = Path(sys.executable or "")
sibling = exe.parent / ("hermes.exe" if sys.platform == "win32" else "hermes")
if sibling.is_file():
return str(sibling)
return shutil.which("hermes") or "hermes"
def local_delivery_command(profile: str, query_file: str) -> list[str]:
"""argv that delivers a DM into ``profile``'s Bot Chat on THIS gateway."""
return [_hermes_cli(), "-p", profile, *BOT_CHAT_TURN_ARGS, "--query-file", query_file]
# ── per-profile turn lock ────────────────────────────────────────────────────
#
# Two deliveries into the SAME profile must never run Bot Chat turns
# concurrently. Deliveries are separate ``hermes`` subprocesses, so the lock is
# a per-profile lockfile under ``<root>/bot_relay/locks/`` held with
# ``fcntl.flock`` for exactly the turn window; the kernel releases it on fd
# close (including process death), so a crashed turn can never wedge the
# profile. Waiters are bounded by ``bot_mode.turn_wait_seconds`` and then fail
# with a structured 'target_busy' refusal.
class TurnBusyError(RuntimeError):
"""A delivery turn is already running for the target profile.
``waited_seconds``: roughly how long the caller queued before giving up.
"""
reason = "target_busy"
def __init__(self, profile: str, waited_seconds: float):
self.profile = profile
self.waited_seconds = waited_seconds
super().__init__(
f"target_busy: another delivery turn is already running for "
f"profile '{profile}' — queued behind it for ~{int(round(waited_seconds))}s "
"without it finishing. The message was NOT delivered; retry shortly."
)
def turn_wait_seconds() -> float:
"""Wait budget for a queued delivery turn (config, lazily read)."""
try:
from hermes_cli.config import cfg_get, load_config
val = cfg_get(load_config(), "bot_mode", "turn_wait_seconds", default=None)
if val is not None:
return max(0.0, float(val))
except Exception:
logger.debug("bot_mode.turn_wait_seconds read failed", exc_info=True)
return float(TURN_WAIT_SECONDS_FALLBACK)
def turn_lock_path(root: Path | str, profile: str) -> Path:
"""Per-profile lockfile path (short — safe on macOS temp roots)."""
safe = re.sub(r"[^a-zA-Z0-9_-]", "_", str(profile or ""))[:64] or "_"
return relay_root(root) / LOCKS_DIR / f"{safe}.lock"
@contextlib.contextmanager
def acquire_turn_lock(
root: Path | str, profile: str, timeout_seconds: float | None = None
) -> Iterator[Path]:
"""Hold ``profile``'s cross-process turn lock for the ``with`` body.
Non-blocking flock probe + short-sleep retry up to the budget
(``bot_mode.turn_wait_seconds`` unless ``timeout_seconds`` is given). No
ordering among waiters, but every waiter is bounded — no deadlock. Raises
:class:`TurnBusyError` when the budget is exhausted. Without ``fcntl``
(Windows) the lock is a no-op — those installs never had this race path
in production.
"""
try:
import fcntl
except ImportError: # pragma: no cover — Windows
logger.debug("bot turn lock disabled: fcntl unavailable on this platform")
yield turn_lock_path(root, profile)
return
budget = turn_wait_seconds() if timeout_seconds is None else max(0.0, float(timeout_seconds))
path = turn_lock_path(root, profile)
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
try:
start = time.monotonic()
deadline = start + budget
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except OSError:
now = time.monotonic()
if now >= deadline:
raise TurnBusyError(profile, now - start)
time.sleep(min(0.1, max(0.005, deadline - now)))
try:
yield path
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError: # pragma: no cover — kernel releases on close anyway
pass
finally:
os.close(fd)