Files
hermes-agent/tools/bot_relay.py
teknium1 664c7e7d4f fix(bot-relay): re-offer only past the Desktop's deliver deadline, once per envelope, with a typed timeout after the waiter's budget
The re-offer window (2 x 600 s = 1200 s) was shorter than a legitimate in-flight
bot_relay.deliver hold (lock wait + two attempts = 1320 s) and than the
Desktop's own deliver deadline (1500 s), so a slow-but-live delivery with no
reply on disk yet was handed out AGAIN by the next drain: a double turn on the
target (or target_busy), whose fast error then won first-settled-wins over the
real answer. And every re-offer bumped the mtime and opened a new window, so an
envelope nobody ever answered was re-offered every 1200 s forever - the 6 h sweep
is mtime-based too - long after the sender's waiter had given up.

- REOFFER_AFTER_SECONDS = DESKTOP_DELIVER_TIMEOUT_SECONDS + 60: past the point
  where the Desktop has provably posted its own delivery_timeout reply (the
  gateway-side hold ends before it by construction), silence means a dead
  Desktop. The false "never in flight that long" comment is gone.
- REPLY_WAIT_SECONDS = REOFFER_AFTER_SECONDS + DESKTOP_DELIVER_TIMEOUT_SECONDS
  + 60, so the waiter is still listening when the one re-offered delivery hits
  its own deadline.
- One re-offer per envelope (`reoffered_at` stamped on the claimed file); once
  created_at + REPLY_WAIT_SECONDS passes unanswered the drain writes a
  delivery_timeout reply instead, so the sender learns and no turn loop runs
  against a target nobody is waiting for. Age is created_at, not bumped mtime.
- bot_mode.envelope_ttl_seconds applies to the re-offer leg exactly as to the
  outbox: the message is back in the queue from claim + REOFFER_AFTER_SECONDS,
  and a drain that comes a whole TTL later refuses it with queued_expired.
- write_reply's first-settled-wins is now documented as safe BECAUSE two
  deliveries of one envelope can no longer overlap.

The existing constants test pins REOFFER > Desktop deadline > live hold and
REPLY_WAIT > REOFFER + Desktop deadline; the drain-side test drives the real
outbox.drain handler through re-offer, no second re-offer, and the timeout
reply. Docs bullet reworded to the new window.
2026-09-20 11:51:14 -07:00

677 lines
35 KiB
Python

"""Bot Mode cross-connection relay — connections ARE the peer set.
Gateway-side half of the relay letting agents on ANY Desktop-connected gateway
message agents on ANY other. Plain file plumbing under ``<root>/bot_relay/`` —
no network; the Desktop owns every socket: ``roster.json`` (union roster of
agents on OTHER connections, pushed via ``bot_relay.roster.sync``), ``outbox/``
(envelopes queued by ``message_agent``, drained via ``bot_relay.outbox.drain``),
``replies/`` (one JSON per envelope via ``bot_relay.reply``; a waiter spawned at
send time watches it so the reply wakes the sender like a local DM).
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 time
import uuid
from pathlib import Path
from typing import Any, Iterator, Mapping, Optional
from tools.bot_mode_probe import _default_home, _hermes_root, alias_forms
from utils import atomic_json_write
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"
# Config fallbacks (real knobs: ``bot_mode.turn_wait_seconds`` / ``bot_mode.envelope_ttl_seconds``).
TURN_WAIT_SECONDS_FALLBACK = 120
DEFAULT_ENVELOPE_TTL_SECONDS = 900 # older envelopes are refused at drain with 'queued_expired'
# Per-attempt turn timeout and attempt ceiling for bot_relay.deliver (tui_gateway/methods_bot_relay.py).
TURN_ATTEMPT_TIMEOUT_SECONDS = 600
TURN_MAX_ATTEMPTS = 2 # first attempt + the policy-gated re-run
# Mirrors RELAY_DELIVER_TIMEOUT_MS in apps/desktop/src/plugins/hermes-bots/relay.ts; both test suites pin it.
DESKTOP_DELIVER_SETTLEMENT_MARGIN_SECONDS = 180
DESKTOP_DELIVER_TIMEOUT_SECONDS = (
TURN_WAIT_SECONDS_FALLBACK + TURN_ATTEMPT_TIMEOUT_SECONDS * TURN_MAX_ATTEMPTS + DESKTOP_DELIVER_SETTLEMENT_MARGIN_SECONDS
)
# A claimed envelope still unanswered this long after its claim was taken by a Desktop that died
# before ``bot_relay.deliver``; the next drain re-offers it, once. The longest a LIVE delivery can
# be in flight without a reply on disk is the Desktop's own deliver deadline (it posts a
# ``delivery_timeout`` reply when that passes, and the gateway-side hold — lock wait + the turn
# attempts — ends before it by construction), so past that point plus posting headroom the silence
# is provably the Desktop's death, not a slow turn. tests/tools/test_bot_relay.py pins the order.
REOFFER_AFTER_SECONDS = DESKTOP_DELIVER_TIMEOUT_SECONDS + 60
# The waiter must outlive the Desktop's timeout reply for the first delivery AND for the one
# re-offered delivery: re-offer window + a whole deliver budget + posting headroom.
REPLY_WAIT_SECONDS = REOFFER_AFTER_SECONDS + DESKTOP_DELIVER_TIMEOUT_SECONDS + 60
# Envelopes/replies older than this are stale artifacts (Desktop closed) and are swept.
STALE_AFTER_SECONDS = 6 * 3600
# Only a recent roster is authoritative for the fail-fast offline check: the
# Desktop re-pushes roster.sync on connection-state changes.
ROSTER_FRESH_SECONDS = 600
class EnvelopeRefusedError(RuntimeError):
"""``enqueue_envelope`` refused to queue (nothing written); ``reason`` is a stable machine code.
``reason`` is a stable machine code; ``str(exc)`` is the human text. 'runtime_offline' matches the
#93091 item-1 failure-reason enum (plain literal here so the branches merge cleanly).
"""
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")
# Set by a dispatcher on the ONE policy-gated re-run of a failed delivery turn (``tools.bot_mode_dm``,
# ``tui_gateway.methods_bot_relay``). The failed attempt's turn-start persist already left the DM as the
# Bot Chat's unanswered tail row, and a fresh process cannot tell that from a new message on its own — so
# the re-run is told to adopt that row instead of appending a second copy
# (``hermes_cli.quiet_single_query.adopt_unanswered_turn``, which consumes the variable before the turn).
RESUME_UNANSWERED_TURN_ENV = "HERMES_RESUME_UNANSWERED_TURN"
def retry_turn_env(env: Optional[Mapping[str, str]]) -> dict[str, str]:
"""The re-run's child env: the first attempt's env plus the resume marker."""
return {**(os.environ if env is None else env), RESUME_UNANSWERED_TURN_ENV: "1"}
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):
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(base / sub)
return base
def _atomic_write_json(target: Path, payload: Any, *, sort_keys: bool = False) -> None:
atomic_json_write(target, payload, indent=None, sort_keys=sort_keys, mode=0o600)
def _bot_mode_cfg(key: str, *, loader: str) -> Any:
"""``bot_mode.<key>`` from config, read lazily (tools/ must not import CLI
config at import time); None when absent or the config is unreadable."""
try:
import hermes_cli.config as cfgmod
cfg = getattr(cfgmod, loader)() or {}
return (cfg.get("bot_mode") or {}).get(key)
except Exception:
logger.debug("bot_mode.%s config read failed", key, exc_info=True)
return None
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."""
if not isinstance(row, dict):
return None
profile = str(row.get("profile") or "").strip()
handle = str(row.get("handle") or "").strip().lstrip("@") or ("hermes" if profile == "default" else profile)
connection_id = str(row.get("connection_id") or "").strip()
if not profile or not connection_id or not all(_HANDLE_RE.match(v) for v in (handle, profile, 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],
}
# Liveness kept only when a real bool: 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 norm in filter(None, map(_normalize_roster_row, rows if isinstance(rows, list) else [])):
by_key.setdefault((norm["connection_id"], norm["profile"]), norm)
cleaned = [by_key[k] for k in sorted(by_key)]
_atomic_write_json(base / ROSTER_FILE, {"updated_at": int(time.time()), "agents": cleaned}, sort_keys=True)
return len(cleaned)
def read_remote_roster(root: Path | str) -> list[dict]:
"""The current remote roster (possibly empty). Never raises."""
try:
data = json.loads((relay_root(root) / ROSTER_FILE).read_text(encoding="utf-8"))
agents = data.get("agents") if isinstance(data, dict) else None
return [r for r in map(_normalize_roster_row, agents) if r] if isinstance(agents, list) else []
except FileNotFoundError:
return []
except Exception:
logger.debug("bot_relay roster read failed", exc_info=True)
return []
def _target_ids(row: dict) -> set[str]:
"""Lower-cased routing ids of a roster row: its @handle and profile folder id."""
return {row["handle"].lower(), row["profile"].lower()}
def _target_aliases(row: dict) -> set[str]:
"""Every lower-cased bare form that addresses ``row``: routing ids plus the Bot Mode title's
mention slugs (``"CoS Bot"`` → ``cos-bot``/``cosbot``, what the Desktop picker inserts). A remote
``default`` is ``@hermes`` on every gateway, so its title is the only bare form that can single it out."""
return _target_ids(row) | alias_forms(row.get("title") or "")
def resolve_remote_target(raw_target: str, roster: list[dict]) -> Any:
"""Matched row for a bare handle/profile/title slug (unique across connections) or
``<handle|profile|title-slug>@<connection-id>``; ``"ambiguous"`` for a bare form on several
connections; None otherwise. An exact handle/profile match beats a title slug, so a title
colliding with another row's handle never steals it."""
want, at, conn = (p.strip() for p in str(raw_target or "").strip().lstrip("@").partition("@"))
if not want or (at and not conn):
return None
want = want.lower()
rows = [row for row in roster if not conn or row["connection_id"].lower() == conn.lower()]
matches = [row for row in rows if want in _target_ids(row)] or [row for row in rows if want in _target_aliases(row)]
if not matches:
return None
return matches[0] if len(matches) == 1 else "ambiguous"
def _title_slug(row: dict) -> str:
"""The Bot Mode title's slug form (``"CoS Bot"`` → ``cos-bot``, what the picker inserts); "" when the
title is empty, reserved (a bot titled "Hermes") or not a valid handle."""
title = str(row.get("title") or "")
slug = re.sub(r"[^a-z0-9_-]+", "-", title.strip().lower()).strip("-")
return slug if slug in alias_forms(title) else ""
def remote_target_forms(roster: list[dict], local_taken: "set[str] | frozenset[str]" = frozenset()) -> list[str]:
"""One unambiguous target string per row, shortest first: the bare handle when no other remote
row and no LOCAL profile (``local_taken``: this gateway's handles and friendly-name slugs) answers
to it; else the title slug under the same test (a remote ``default`` titled "CoS Bot" is
``@cos-bot``, since bare ``@hermes`` is always this gateway's own default); else
``handle@connection``. Mirrors ``resolve_remote_target``."""
taken = {form.lower() for form in local_taken}
id_claims: dict[str, int] = {}
alias_claims: dict[str, int] = {}
for row in roster:
for form in _target_ids(row):
id_claims[form] = id_claims.get(form, 0) + 1
for form in _target_aliases(row):
alias_claims[form] = alias_claims.get(form, 0) + 1
def _form(row: dict) -> str:
# The handle needs only be unique among routing ids (resolution gives it precedence over a
# colliding title); a title slug must be unique among every alias.
for candidate, claims in ((row["handle"], id_claims), (_title_slug(row), alias_claims)):
if candidate and candidate.lower() not in taken and claims.get(candidate.lower(), 0) == 1:
return candidate
return f"{row['handle']}@{row['connection_id']}"
return [_form(row) for row in roster]
_SENDER_STAMP_RE = re.compile(r"^(Message from 🤖 .+? \(@)([A-Za-z0-9_-]+)(\): )", re.DOTALL)
def qualify_sender_stamp(message: str, from_handle: Any, from_connection: Any, roster: list[dict],
local_taken: "set[str] | frozenset[str]" = frozenset()) -> str:
"""Rewrite a relayed DM's ``Message from 🤖 <name> (@<handle>):`` stamp so the handle is the
form THIS gateway can reply to: the sender's row in the local relay roster as
``remote_target_forms`` renders it, else ``handle@connection``. A relayed ``@hermes`` is another
machine's default — left bare, a reply lands on the recipient's own default (#103731)."""
handle, conn = str(from_handle or "").strip().lstrip("@"), str(from_connection or "").strip()
match = _SENDER_STAMP_RE.match(str(message or ""))
if not match or not conn or not _HANDLE_RE.match(handle) or not _HANDLE_RE.match(conn):
return message
forms = dict(zip(((r["connection_id"].lower(), r["handle"].lower()) for r in roster), remote_target_forms(roster, local_taken)))
form = forms.get((conn.lower(), handle.lower())) or f"{handle}@{conn}"
return f"{match.group(1)}{form}{match.group(3)}{message[match.end():]}"
def _envelope_ttl_seconds() -> int:
"""Configured drain TTL (``bot_mode.envelope_ttl_seconds``), read per-drain.
``0`` (or negative) disables expiry."""
val = _bot_mode_cfg("envelope_ttl_seconds", loader="load_config_readonly")
if val is None:
return DEFAULT_ENVELOPE_TTL_SECONDS
try:
return int(val)
except (TypeError, ValueError, OverflowError):
logger.debug("Invalid bot_mode.envelope_ttl_seconds %r; using fallback", val)
return DEFAULT_ENVELOPE_TTL_SECONDS
def _target_liveness(root: Path | str, target: dict) -> Optional[bool]:
"""Tri-state liveness: True / False / None (unknown → callers fail open). Offline =
explicit ``online: false`` or ABSENT from a *fresh* roster; a missing, unreadable,
empty or stale roster proves nothing → None. Never raises."""
try:
try:
age = time.time() - (relay_root(root) / ROSTER_FILE).stat().st_mtime
except OSError:
return None
roster = read_remote_roster(root) if age <= ROSTER_FRESH_SECONDS else []
if not roster:
return None
key = (str(target.get("connection_id") or ""), str(target.get("profile") or ""))
row = next((r for r in roster if (r["connection_id"], r["profile"]) == key), None)
if row is None:
return False # fresh roster no longer lists the target — offline
return row["online"] if isinstance(row.get("online"), bool) else None
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 the 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)
return envelope
def _expire_if_stale(root: Path | str, path: Path, ttl: float, now: float) -> bool:
"""True when the outbox envelope is older than ``ttl``; writes the 'queued_expired'
reply so the sender's waiter resolves (best effort). Unreadable envelopes are left for the claim."""
try:
env = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(env, dict):
raise ValueError(f"expected a JSON object, got {type(env).__name__}")
created = float(env.get("created_at") or path.stat().st_mtime)
except (OSError, ValueError):
return False
if now - created <= ttl:
return False
with contextlib.suppress(OSError, ValueError):
write_reply(root, str(env.get("id") or ""), reason="queued_expired", error=(
f"queued message to @{env.get('target_handle') or '?'} on {env.get('target_connection') or '?'} "
f"expired after {ttl}s waiting for the Desktop to drain it — it was NOT delivered. "
"Resend once the Desktop reconnects."))
return True
def _queued_at(path: Path) -> tuple[float, str]:
"""Claim order for one outbox entry: oldest first. ``mtime`` is what ``_sweep_stale`` already
treats as an envelope's age, and unlike the whole-second ``created_at`` field it separates two
DMs sent in the same second. The name only breaks ties."""
with contextlib.suppress(OSError):
return (path.stat().st_mtime, path.name)
return (0.0, path.name)
def claim_pending_envelopes(root: Path | str) -> list[dict]:
"""Drain the outbox (rename → claimed/ so a second drain can't double-deliver).
TTL-expired envelopes get a 'queued_expired' reply and are removed instead.
Envelopes older than ``bot_mode.envelope_ttl_seconds`` are NOT delivered: each gets an error reply
(reason ``'queued_expired'``) so the sender's waiter resolves, and its outbox file is removed (#93091
item 2).
"""
base = _ensure_dirs(root)
_sweep_stale(base)
ttl = _envelope_ttl_seconds()
now = time.time()
# Re-offers first: they are the oldest mail this drain hands out.
out: list[dict] = _reoffer_unanswered(root, base, ttl, now)
# Oldest first: the Desktop delivers each target's claimed envelopes in the order this list
# gives them, so a sender's two DMs to one agent arrive in the order they were sent. Sorting
# by filename ordered them by ``uuid4().hex`` — at random.
for path in sorted((base / OUTBOX_DIR).glob("*.json"), key=_queued_at):
if ttl > 0 and _expire_if_stale(root, path, ttl, now):
with contextlib.suppress(OSError):
path.unlink()
continue
claimed = base / CLAIMED_DIR / path.name
with contextlib.suppress(OSError, ValueError):
os.replace(path, claimed) # atomic claim
os.utime(claimed, (now, now)) # the re-offer window counts from the claim, not the enqueue
envelope = json.loads(claimed.read_text(encoding="utf-8"))
if not isinstance(envelope, dict):
raise ValueError(f"expected a JSON object, got {type(envelope).__name__}")
out.append(envelope)
return out
def _reoffer_unanswered(root: Path | str, base: Path, ttl: float, now: float) -> list[dict]:
"""``claimed/`` envelopes unanswered ``REOFFER_AFTER_SECONDS`` after their claim, at most once each.
The claim is the Desktop's: one that disconnects between ``outbox.drain`` and ``bot_relay.deliver``
leaves the envelope here with no reply, silent until the waiter's deadline and then swept, while
the reconnected Desktop's drains see an empty outbox (#111021, #111207). Bounds, in check order:
* ``created_at + REPLY_WAIT_SECONDS`` passed with no reply — the waiter is gone (or about to be);
a ``delivery_timeout`` reply is written so it learns, and the envelope is never handed out again.
* already re-offered (``reoffered_at`` stamped on the envelope) — one extra delivery per message,
never a turn loop against a target nobody is listening for.
* ``bot_mode.envelope_ttl_seconds`` applies to the re-offer leg exactly as to the outbox: the
message is back in the queue from ``claim + REOFFER_AFTER_SECONDS``; a drain that comes ``ttl``
later than that refuses it with ``queued_expired``.
"""
out: list[dict] = []
for path in sorted((base / CLAIMED_DIR).glob("*.json"), key=_queued_at):
if (base / REPLIES_DIR / path.name).exists():
continue
with contextlib.suppress(OSError, ValueError):
claimed_at = path.stat().st_mtime
envelope = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(envelope, dict):
raise ValueError(f"expected a JSON object, got {type(envelope).__name__}")
env_id = str(envelope.get("id") or "")
label = f"@{envelope.get('target_handle') or '?'} on {envelope.get('target_connection') or '?'}"
created = float(envelope.get("created_at") or claimed_at)
if now - created > REPLY_WAIT_SECONDS:
write_reply(root, env_id, reason="delivery_timeout", error=(
f"no reply from {label} within {REPLY_WAIT_SECONDS}s of sending — the Desktop picked "
"the message up but never reported a delivery. It will not be retried; resend if it matters."))
continue
if envelope.get("reoffered_at"):
continue
queued_for = now - claimed_at - REOFFER_AFTER_SECONDS
if queued_for < 0:
continue
if ttl > 0 and queued_for > ttl:
write_reply(root, env_id, reason="queued_expired", error=(
f"re-queued message to {label} expired after {ttl}s waiting for the Desktop to drain it "
"again — it was NOT delivered. Resend once the Desktop reconnects."))
continue
envelope["reoffered_at"] = int(now)
_atomic_write_json(path, envelope)
out.append(envelope)
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`` (typed
code, ``tools.bot_failure_reasons``) is classified from ``error`` when omitted.
Idempotent by envelope id — the first settled reply stands, error or not. That is safe because
two deliveries of one envelope never overlap: ``_reoffer_unanswered`` waits past the Desktop's own
deliver deadline (``REOFFER_AFTER_SECONDS``), so by the time a second delivery can start, the first
has either replied (and the waiter may already have read it) or provably died without one. A
later write is therefore a duplicate or a bookkeeping timeout, never a truer answer."""
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}")
path = base / REPLIES_DIR / f"{safe}.json"
if path.exists():
# Idempotent by envelope id: the first settled reply is the one the waiter already read (or
# will). A re-offered delivery's second outcome — or a late duplicate — never displaces it.
return path
err, code = str(error or ""), str(reason or "")
if not code and err:
from tools.bot_failure_reasons import classify_agent_error
code = classify_agent_error(err)
_atomic_write_json(path, {"id": safe, "at": int(time.time()), "reply": str(reply or ""), "error": err, "reason": code})
return path
def unlink_files_older_than(directory: Path, pattern: str, cutoff: float) -> int:
"""Unlink regular files matching ``pattern`` with mtime before ``cutoff``; returns count. Never raises."""
removed = 0
with contextlib.suppress(OSError):
for path in directory.glob(pattern):
with contextlib.suppress(OSError):
if path.is_file() and path.stat().st_mtime < cutoff:
path.unlink()
removed += 1
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:
"""Hourly sweep of stale relay artifacts (DM plaintext; ``_sweep_stale`` otherwise runs
only on Desktop drains). ``max_age_hours`` is for ``cleanup_*_cache`` signature parity only."""
del max_age_hours
try:
base = relay_root(_hermes_root(Path(_default_home())))
return _sweep_stale(base) if base.is_dir() else 0
except Exception:
logger.debug("bot_relay artifact sweep failed", exc_info=True)
return 0
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 arrives
as the same completion notification local DMs use.
A ``tools/bot_mode_dm.py --wait-reply`` entrypoint, like the local delivery runner — not
``python -c``. The approval gate flags inline interpreter code ("script execution via -e/-c
flag"), and ``approvals.single_query_mode`` defaults to ``deny`` for the one-shot ``-Q`` turn a
bot replies from, so the reply waiter was refused exactly when a bot answered a teammate: the
message was delivered, the reply never woke the sender. Roster fields ride as argv (``shlex``
quoted), never as source text, so a hostile handle or connection id stays data.
"""
reply_path = str(relay_root(root) / REPLIES_DIR / f"{envelope['id']}.json")
label = f"@{envelope.get('target_handle', '')} on {envelope.get('target_connection', '')}"
runner = str(Path(__file__).resolve().with_name("bot_mode_dm.py"))
argv = [sys.executable or "python3", runner, "--wait-reply", reply_path, label, str(REPLY_WAIT_SECONDS)]
if sys.platform == "win32":
# Same rewrite as the delivery runner: the tracked local backend uses Git Bash on native
# Windows, where forward-slash drive paths run and backslash paths parse as command names.
argv = [part.replace("\\", "/") for part in argv]
return shlex.join(argv)
def _hermes_cli() -> str:
"""hermes CLI beside this interpreter, then ``shutil.which``, then the bare name
(service contexts lack PATH, so a bare "hermes" died with ENOENT).
The deliver RPC runs on the target gateway, whose process is the venv python — its bin/Scripts directory
holds the matching ``hermes`` entrypoint. A bare ``"hermes"`` relies on PATH, which is exactly what
service contexts (systemd units, desktop launchers, non-login SSH shells) do not provide, so delivery
died with ENOENT there (#93590). When no sibling exists (e.g. running from a source tree without an
installed script), a ``shutil.which`` lookup runs next — it honors whatever PATH the process does have —
before falling back to the bare name, preserving today's behavior for interactive shells.
"""
sibling = Path(sys.executable or "").parent / ("hermes.exe" if sys.platform == "win32" else "hermes")
return str(sibling) if sibling.is_file() else 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]
class DeliveryAuthor:
"""A relayed turn's author as an in-process object. ``bot_relay.deliver`` builds it from the sender fields
an admitted gateway client relays for another connection; nothing verifies the sender itself. A JSON
client cannot build one, so ``prompt.submit`` accepts the object and refuses a dict."""
__slots__ = ("author",)
def __init__(self, author: dict) -> None:
self.author = dict(author)
def __eq__(self, other: object) -> bool:
return isinstance(other, DeliveryAuthor) and other.author == self.author
def __repr__(self) -> str:
return f"DeliveryAuthor({self.author!r})"
def delivery_turn_author(from_profile: Any, from_handle: Any, from_connection: Any = None) -> Optional[dict]:
"""The author of a relayed DM's recipient turn, built from the sender fields as the relaying client reports
them. A relayed DM always comes from another gateway, so the id carries the Desktop's id for the sender's
connection (``local`` included) and only the recipient's own profiles are bare ``bot:<profile>``. None when
the envelope names no sender."""
from agent.turn_author import bot_author_id
profile = str(from_profile or "").strip()
if not profile:
return None
return {"id": bot_author_id(profile, str(from_connection or "")), "name": str(from_handle or "").strip() or profile,
"is_bot": True}
def _delivery_child_session_env_names() -> "tuple[str, ...]":
"""Session-bound env names to strip from a delivery child, from ``gateway.session_context``.
Synced with the session binding surface as vars are added; deliberately NOT a
``HERMES_SESSION_*`` prefix match, which would also strip non-identity knobs
(e.g. ``HERMES_SESSION_STALL_TIMEOUT``)."""
from gateway.session_context import _VAR_MAP
return tuple(_VAR_MAP)
def relaying_principal_author(principal: str) -> dict:
"""The author of a relayed DM whose sender fields cannot be trusted: a logged-in client named them.
Server-derived and unspoofable — the id is built from the caller's minted identity digest, never from
anything the client sent — and still a BOT author, because the recipient's memory routes on that:
Honcho writes a bot-authored turn into the bot's own a2a session and refuses conclusion / profile /
mirror writes for it, while an unattributed turn is treated as the human's (#107598 review). The
human-facing signature stays in the message text the sender composed."""
from agent.turn_author import bot_author_id
return {"id": bot_author_id("relay", str(principal or "").strip()), "name": "relayed teammate", "is_bot": True}
def delivery_env(author: Optional[dict], profile_home: "str | Path | None" = None) -> dict[str, str]:
"""Environment for one delivery turn's ``hermes -p <profile>`` child. The dispatcher's own
HERMES_TURN_AUTHOR is dropped first so a delivery without an author never inherits the author of the turn
that sent it. Dispatcher session identity (the canonical ``gateway.session_context`` session env names) is
dropped too: a nested recipient that ``message_agent``s onward must not stamp that grandchild
notify with the grandparent's key, or the live recipient never resumes. The child runs the target
profile's Bot Chat turn, so it starts from THAT profile's env (``served_profile_child_env``: launch
profile ``.env`` / TERMINAL_* residue dropped, target secrets overlaid), never the multiplexer's raw
``os.environ``; ``-p`` alone only pinned HERMES_HOME. ``profile_home`` is the target's home when the
caller knows it (relay RPC, roster); otherwise the active override."""
from agent.turn_author import TURN_AUTHOR_ENV, turn_author_env
from tools.environments.local import served_profile_child_env
env = served_profile_child_env(base=os.environ, target_home=profile_home, inherit_credentials=True)
env.pop(TURN_AUTHOR_ENV, None)
for name in _delivery_child_session_env_names():
env.pop(name, None)
if author:
env.update(turn_author_env(author))
return env
# 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 (incl. process death), so a
# crashed turn can never wedge the profile.
# ── per-profile turn lock (#93091) ─────────────────────────────────────────── Two deliveries into the SAME
# target profile must never run their Bot Chat turns concurrently: deliveries spawn separate ``hermes``
# subprocesses, so an in-memory mutex is useless — the lock is a per-profile lockfile under
# ``<root>/bot_relay/locks/`` held with ``fcntl.flock`` for exactly the turn execution window. flock is
# released by the kernel when the holder's fd closes (including process death), so a crashed turn can never
# wedge the profile. A queued delivery waits up to ``bot_mode.turn_wait_seconds`` and then fails with a
# structured 'target_busy' refusal instead of blocking forever.
class TurnBusyError(RuntimeError):
"""A delivery turn is already running for the target profile (``waited_seconds`` ≈ time queued).
``reason`` is 'target_busy' — extends the #93091 item-1 structured refusal enum. ``waited_seconds`` is
roughly how long the caller queued behind the current turn before giving up.
"""
reason = "target_busy"
def __init__(self, profile: str, waited_seconds: float):
self.profile, self.waited_seconds = profile, waited_seconds
super().__init__(f"target_busy: another delivery turn is already running for profile '{profile}' — "
f"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)."""
val = _bot_mode_cfg("turn_wait_seconds", loader="load_config")
return float(TURN_WAIT_SECONDS_FALLBACK) if val is None else max(0.0, float(val))
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``); raises :class:`TurnBusyError` when exhausted. No
ordering among waiters, but every waiter is bounded. Without ``fcntl`` (Windows)
the lock is a no-op — those installs never had this race path."""
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:
with contextlib.suppress(OSError): # kernel releases on close anyway
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)