mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Review round on #148903 (Greptile 4467193888..4467194077): - Bot Mode gate (agent/bot_chain.py, tools/bot_mode_dm_chain.py) runs once per message_agent call before any transport, on #144789's code. Recipients are keyed by the author id their turns carry (bot:default for @hermes, bot:<conn>/<profile> for relay rows, the peer's bot:<host>/<profile> or peer:<name>/<agent>). - Every call inside a bot-authored turn is one more hop; a person's turn sends at 1. - A definite no-send (offline relay target, refused admission) releases its ask and hop; queued/ambiguous keep them. - A reply to the turn's sender reuses #144789's notify-only path (marker + wake="none"); the receiver reads the marker from the text, so it survives the Desktop relay, which rebuilds the author. The parallel expects_reply author field is gone. - Forks (_turn_origin) neither reset nor advance the chain; the chain is bound after compression recovery and follows a session id change (rotation carry and per-agent rebinding). - gateway.bot_loop_guard.enabled: false switches the Bot Mode rules off too. - Gateway: gateway/bot_peer_notices.automatic_notices_allowed is the one policy at every runtime-notice egress (platform notices incl. delegation failure, inactivity warning, hygiene, background failure, approval timeout, command acks, context-ref refusals, STT echo, status/progress lanes). - A bot may end in silence even when it addressed us (reply_expected=True). - reply_to_reply keys on a reply this bot DELIVERED (note_reply), not on admission. - Tests split by owner: tests/agent/test_bot_chain.py, tests/tools/test_bot_mode_dm_chain.py, gateway ingress stays; config keys covered through the real config.yaml loader. Each finding's test fails with its guard removed (mutant run).
109 lines
4.5 KiB
Python
109 lines
4.5 KiB
Python
"""Who wrote the user side of a turn, carried from the dispatcher into the recipient's turn.
|
|
|
|
The dispatcher sets ``HERMES_TURN_AUTHOR`` on the recipient's one-shot subprocess only. A cached
|
|
gateway agent sees several authors over its lifetime, so the author is read per turn, never per agent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import unicodedata
|
|
from typing import Any, Dict, Mapping, MutableMapping, Optional
|
|
|
|
TURN_AUTHOR_ENV = "HERMES_TURN_AUTHOR"
|
|
|
|
_MAX_FIELD_LEN = 200
|
|
_MAX_CHAIN_HOPS = 1000
|
|
|
|
|
|
_DROPPED_CATEGORIES = frozenset({"Cc", "Cs", "Cn", "Co"})
|
|
_TRUTHY = frozenset({"true", "1", "yes"})
|
|
|
|
|
|
def _clean_text(value: Any) -> Optional[str]:
|
|
"""Strip whitespace, control and unassigned characters, then cap the length. None when nothing is left.
|
|
Format characters and non-breaking spaces stay so emoji sequences and display names survive."""
|
|
if not isinstance(value, str):
|
|
return None
|
|
text = "".join(ch for ch in value if unicodedata.category(ch) not in _DROPPED_CATEGORIES).strip()
|
|
if not text:
|
|
return None
|
|
return text[:_MAX_FIELD_LEN]
|
|
|
|
|
|
def _bot_flag(value: Any) -> bool:
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in _TRUTHY
|
|
return isinstance(value, (bool, int)) and bool(value)
|
|
|
|
|
|
def bot_author_id(profile: str, origin: Optional[str] = None) -> str:
|
|
"""``bot:<profile>`` for a bot on the recipient's own install, ``bot:<origin>/<profile>`` for one on another
|
|
install. The origin is the Desktop's connection id on a relayed dm and the sender's hostname on a peer dm."""
|
|
origin = (origin or "").strip()
|
|
return f"bot:{origin}/{profile}" if origin else f"bot:{profile}"
|
|
|
|
|
|
def local_origin() -> str:
|
|
"""This machine's hostname as an author-id origin, cleaned like any author field. Empty when unknown."""
|
|
try:
|
|
host = socket.gethostname()
|
|
except Exception:
|
|
return ""
|
|
# A slash would split the id into a different origin and profile at parse time.
|
|
return (_clean_text(host) or "").replace("/", "")
|
|
|
|
|
|
def parse_turn_author(raw: Any) -> Optional[dict[str, Any]]:
|
|
"""Normalize a dict or JSON string into ``{"id", "name", "is_bot"}``; None for anything else or without id and name.
|
|
The id is whatever the transport knows the sender by: ``bot:<profile>`` on a bot-mode delivery inside one
|
|
install, ``bot:<connection>/<profile>`` when the Desktop relayed it from another machine,
|
|
``bot:<hostname>/<profile>`` on a peer dm, the platform user id elsewhere.
|
|
An ``origin`` field qualifies a bare ``bot:<profile>`` id the same way."""
|
|
try:
|
|
if isinstance(raw, (str, bytes)):
|
|
raw = json.loads(raw)
|
|
if not isinstance(raw, Mapping):
|
|
return None
|
|
author = {
|
|
"id": _clean_text(raw.get("id")),
|
|
"name": _clean_text(raw.get("name")),
|
|
"is_bot": _bot_flag(raw.get("is_bot")),
|
|
}
|
|
if author["id"] is None and author["name"] is None:
|
|
return None
|
|
# Runtime-written chain fields of a bot-to-bot delivery (agent/bot_chain.py); never model input.
|
|
hops = raw.get("hops")
|
|
if isinstance(hops, int) and not isinstance(hops, bool) and 0 < hops <= _MAX_CHAIN_HOPS:
|
|
author["hops"] = hops
|
|
origin = _clean_text(raw.get("origin"))
|
|
if origin and author["id"] and author["id"].startswith("bot:") and "/" not in author["id"]:
|
|
author["id"] = _clean_text(bot_author_id(author["id"][len("bot:"):], origin))
|
|
return author
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def turn_author_from_env(environ: Mapping[str, str] = os.environ) -> Optional[dict[str, Any]]:
|
|
"""The author the dispatcher placed in ``HERMES_TURN_AUTHOR``, or None."""
|
|
return parse_turn_author(environ.get(TURN_AUTHOR_ENV))
|
|
|
|
|
|
def take_turn_author_from_env(environ: MutableMapping[str, str] = os.environ) -> Optional[dict[str, Any]]:
|
|
"""Read and remove ``HERMES_TURN_AUTHOR`` so subprocesses started during the turn do not inherit it."""
|
|
return parse_turn_author(environ.pop(TURN_AUTHOR_ENV, None))
|
|
|
|
|
|
def turn_author_env(author: dict[str, Any]) -> dict[str, str]:
|
|
"""The environment entry a dispatcher merges into a child's env."""
|
|
return {TURN_AUTHOR_ENV: json.dumps(author, separators=(",", ":"))}
|
|
|
|
|
|
def a2a_key(author: Optional[dict[str, Any]]) -> Optional[str]:
|
|
"""``a2a:<bot id>``, the shared name for a bot author's turns. None for a human or an id-less bot."""
|
|
if not isinstance(author, dict) or not author.get("is_bot") or not author.get("id"):
|
|
return None
|
|
return f"a2a:{author['id']}"
|