feat(bot-mode): message_agent tool — structured, Bot-Chat-only agent-to-agent DMs

Bot Mode agents now DM teammates through a real tool instead of
hand-assembled shell commands. message_agent(target, message) validates
the target against the live roster, applies the sender's attribution
prefix server-side, and delivers over the existing proven transports
(hermes -p ... --query-file for local teammates, hermes peer dm for
peer gateways) as a tracked background process with notify-on-complete
— fire-and-forget, the reply wakes the sender on a later turn.

Containment: the schema is injected per-turn ONLY into a bot's
canonical 'Bot Chat' session on Bot-Mode-managed installs (same gate as
the protocol section); it is never registered in the tool registry or
any toolset, and dispatch re-gates on the session title so a forged
call from any other session refuses. The gate is session-stable, so the
tool list stays byte-identical across turns (prompt-cache safe).

The protocol section is rewritten to teach the tool and now carries the
teammate roster WITH ROLES (Bot Mode title + profile description), so
bots know who does what before picking a recipient. Roles and a
protocol version salt join the capability fingerprint: existing eternal
Bot Chats adopt the v2 protocol + tool with one epoch refresh, and a
rename/description edit refreshes the roster on the next message.
This commit is contained in:
Teknium
2026-08-21 13:38:45 -07:00
parent 04acfb9673
commit e26d91dc11
8 changed files with 848 additions and 35 deletions

View File

@@ -2066,6 +2066,31 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}")
elif function_name == "message_agent":
# Bot Mode teammate DM (tools/bot_mode_dm.py) — injected, not
# registered: only a canonical Bot Chat session carries the
# schema, and the tool re-gates on the session title itself.
def _execute(next_args: dict) -> Any:
from tools.bot_mode_dm import message_agent_tool as _message_agent_tool
return _message_agent_tool(
target=next_args.get("target", ""),
message=next_args.get("message", ""),
task_id=effective_task_id,
agent=agent,
)
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
agent,
function_name=function_name,
function_args=function_args,
effective_task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", "") or "",
execute=_execute,
scope_block=_ts_scope_block,
display_index=i,
))
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('message_agent', function_args, tool_duration, result=function_result)}")
elif function_name == "session_search":
def _execute(next_args: dict) -> Any:
session_db = agent._get_session_db_for_recall()

View File

@@ -746,6 +746,19 @@ def build_turn_context(
active_system_prompt = agent._cached_system_prompt
# Bot Mode DM tool — injected ONLY into a bot's canonical "Bot Chat"
# session on Bot-Mode-managed installs (same gate as the protocol
# section above). The gate is stable for a session's lifetime, so the
# tool list is byte-identical every turn: prompt-cache safe. Every
# other session (CLI, gateway chats, group-room member sessions, cron,
# subagents) fails the gate and never sees the schema.
try:
from tools.bot_mode_dm import ensure_message_agent_tool
ensure_message_agent_tool(agent)
except Exception:
logger.debug("message_agent injection skipped", exc_info=True)
# Create the DB session row now that _cached_system_prompt is populated, so
# the persisted snapshot is written non-NULL on the first turn (Issue
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.

View File

@@ -61,15 +61,26 @@ def test_query_and_query_file_mutually_exclusive(tmp_path):
def test_bot_mode_protocol_never_inlines_message_into_shell():
"""The DM protocol must use --query-file / stdin, not -q "…" inlining."""
"""The DM transport must use --query-file / stdin, not -q "…" inlining.
The transport moved from prompt-injected instructions (bot_mode_probe)
to the message_agent tool (bot_mode_dm) in Aug 2026 — the invariant now
holds on the tool's command builder, and the probe must no longer teach
any shellout at all.
"""
sys.path.insert(0, str(REPO))
try:
import importlib
dm = importlib.import_module("tools.bot_mode_dm")
src = Path(dm.__file__).read_text(encoding="utf-8")
probe = importlib.import_module("tools.bot_mode_probe")
src = Path(probe.__file__).read_text(encoding="utf-8")
probe_src = Path(probe.__file__).read_text(encoding="utf-8")
finally:
sys.path.remove(str(REPO))
assert "--query-file" in src
assert '-q "Message from' not in src
assert 'dm <peer>/<agent-name> "Message from' not in src
# The protocol section teaches the tool, never a hand-rolled shellout.
assert "message_agent" in probe_src
assert "--query-file /tmp/dm.txt" not in probe_src

View File

@@ -0,0 +1,296 @@
"""Tests for tools/bot_mode_dm.py — the Bot-Chat-only ``message_agent`` tool.
The containment contract is the headline here: the tool must exist ONLY in a
canonical Bot Chat session on a Bot-Mode-managed install, and must refuse to
deliver from anywhere else even if a schema leaks.
"""
import json
import textwrap
from pathlib import Path
import pytest
from tools import bot_mode_dm, bot_mode_probe
@pytest.fixture(autouse=True)
def _fresh_probe_cache():
bot_mode_probe._reset_cache_for_tests()
yield
bot_mode_probe._reset_cache_for_tests()
def _managed_home(tmp_path, *, teammates=("researcher",), peers=()) -> Path:
home = tmp_path / ".hermes"
home.mkdir(exist_ok=True)
for name in teammates:
d = home / "profiles" / name
d.mkdir(parents=True, exist_ok=True)
(d / "profile.yaml").write_text(
textwrap.dedent(
"""\
description: teammate for tests
ui_meta:
hermes-bots:
shape: cloud
"""
),
encoding="utf-8",
)
if peers:
lines = ["bot_peers:"]
for peer in peers:
lines += [f" {peer}:", f" url: http://{peer}.lan:8377"]
(home / "config.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
return home
class _FakeDB:
def __init__(self, home: Path, title: str):
self.db_path = str(home / "state.db")
self._title = title
def get_session_title(self, _sid):
return self._title
class _FakeAgent:
def __init__(self, home: Path, title: str = "Bot Chat"):
self._session_db = _FakeDB(home, title)
self.session_id = "sess-1"
self._session_title_hint = None
self._bot_mode_protocol = True
self.tools: list = []
self.valid_tool_names: set = set()
# ── injection gate (leak containment) ────────────────────────────────────────
def test_injects_only_into_bot_chat_on_managed_install(tmp_path):
home = _managed_home(tmp_path)
agent = _FakeAgent(home, title="Bot Chat")
assert bot_mode_dm.ensure_message_agent_tool(agent) is True
names = [t["function"]["name"] for t in agent.tools]
assert names == [bot_mode_dm.MESSAGE_AGENT_TOOL_NAME]
assert bot_mode_dm.MESSAGE_AGENT_TOOL_NAME in agent.valid_tool_names
# idempotent: second call adds nothing (byte-stable tool list per turn)
assert bot_mode_dm.ensure_message_agent_tool(agent) is True
assert len(agent.tools) == 1
@pytest.mark.parametrize(
"title",
["", "My research chat", "Group: room-abc123", "handoff-12ab34cd"],
)
def test_never_injects_outside_bot_chat(tmp_path, title):
"""CLI sessions, ordinary chats, group-room member sessions: no tool."""
home = _managed_home(tmp_path)
agent = _FakeAgent(home, title=title)
assert bot_mode_dm.ensure_message_agent_tool(agent) is False
assert agent.tools == []
assert agent.valid_tool_names == set()
def test_never_injects_on_unmanaged_install(tmp_path):
"""A 'Bot Chat'-titled session on a plain install stays tool-free."""
home = tmp_path / ".hermes"
home.mkdir()
agent = _FakeAgent(home, title="Bot Chat")
assert bot_mode_dm.ensure_message_agent_tool(agent) is False
assert agent.tools == []
def test_config_toggle_disables_injection(tmp_path):
home = _managed_home(tmp_path)
agent = _FakeAgent(home, title="Bot Chat")
agent._bot_mode_protocol = False
assert bot_mode_dm.ensure_message_agent_tool(agent) is False
assert agent.tools == []
def test_schema_never_in_global_registry():
"""message_agent must not be registered/toolset-reachable anywhere."""
from tools.registry import registry
assert bot_mode_dm.MESSAGE_AGENT_TOOL_NAME not in getattr(registry, "_tools", {})
import toolsets
for names in toolsets.TOOLSETS.values():
assert bot_mode_dm.MESSAGE_AGENT_TOOL_NAME not in names
# ── dispatch gate (defense in depth) ─────────────────────────────────────────
def test_tool_refuses_outside_bot_chat(tmp_path):
home = _managed_home(tmp_path)
agent = _FakeAgent(home, title="Ordinary chat")
result = json.loads(
bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent)
)
assert "error" in result
assert "Bot Chat" in result["error"]
def test_tool_refuses_on_unmanaged_install(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
agent = _FakeAgent(home, title="Bot Chat")
result = json.loads(
bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent)
)
assert "error" in result
# ── target validation ────────────────────────────────────────────────────────
def test_unknown_target_lists_roster(tmp_path):
home = _managed_home(tmp_path, teammates=("researcher", "coder"))
agent = _FakeAgent(home, title="Bot Chat")
result = json.loads(
bot_mode_dm.message_agent_tool(target="nosuchbot", message="hi", agent=agent)
)
assert "error" in result
assert set(result["teammates"]) == {"researcher", "coder"}
def test_cannot_message_self(tmp_path):
home = _managed_home(tmp_path)
agent = _FakeAgent(home, title="Bot Chat") # default profile
result = json.loads(
bot_mode_dm.message_agent_tool(target="hermes", message="hi", agent=agent)
)
assert "error" in result
assert "yourself" in result["error"]
def test_empty_and_oversized_message_rejected(tmp_path):
home = _managed_home(tmp_path)
agent = _FakeAgent(home, title="Bot Chat")
assert "error" in json.loads(
bot_mode_dm.message_agent_tool(target="researcher", message=" ", agent=agent)
)
big = "x" * (bot_mode_dm.MESSAGE_MAX_CHARS + 1)
assert "error" in json.loads(
bot_mode_dm.message_agent_tool(target="researcher", message=big, agent=agent)
)
def test_unregistered_peer_rejected(tmp_path):
home = _managed_home(tmp_path, peers=("spark",))
agent = _FakeAgent(home, title="Bot Chat")
result = json.loads(
bot_mode_dm.message_agent_tool(target="homelab/coder", message="hi", agent=agent)
)
assert "error" in result
assert result["peers"] == ["spark"]
# ── delivery command shape ───────────────────────────────────────────────────
def _capture_spawn(monkeypatch):
calls = []
def fake_terminal_tool(command, **kwargs):
calls.append({"command": command, **kwargs})
return json.dumps({"output": "Background process started", "session_id": "proc_test1234"})
import tools.terminal_tool as terminal_tool_module
monkeypatch.setattr(terminal_tool_module, "terminal_tool", fake_terminal_tool)
return calls
def test_local_delivery_command_and_ack(tmp_path, monkeypatch):
calls = _capture_spawn(monkeypatch)
home = _managed_home(tmp_path, teammates=("researcher",))
agent = _FakeAgent(home, title="Bot Chat")
result = json.loads(
bot_mode_dm.message_agent_tool(
target="@researcher",
message='status? give me the "final" numbers $(and this is not shell)',
agent=agent,
)
)
assert result["status"] == "sent"
assert result["to"] == "@researcher"
assert result["process_id"] == "proc_test1234"
assert "do NOT wait" in result["detail"]
assert len(calls) == 1
call = calls[0]
assert call["background"] is True
assert call["notify_on_complete"] is True
command = call["command"]
assert command.startswith("hermes -p researcher chat --in ~ -c \"Bot Chat\"")
assert "--query-file" in command
# message body rides the temp file, never the command line
assert "final" not in command
assert "$(" not in command
# attribution prefix applied server-side; body verbatim inside the file
dm_file = command.rsplit(" ", 1)[-1].strip("'")
content = Path(dm_file).read_text(encoding="utf-8")
assert content.startswith("Message from 🤖 hermes (@hermes): ")
assert '$(and this is not shell)' in content
def test_peer_delivery_command(tmp_path, monkeypatch):
calls = _capture_spawn(monkeypatch)
home = _managed_home(tmp_path, peers=("spark",))
agent = _FakeAgent(home, title="Bot Chat")
result = json.loads(
bot_mode_dm.message_agent_tool(target="spark/researcher", message="ping", agent=agent)
)
assert result["status"] == "sent"
assert "spark" in result["to"]
command = calls[0]["command"]
assert command.startswith("hermes peer dm spark/researcher < ")
# bare peer name targets the peer's main agent
result2 = json.loads(
bot_mode_dm.message_agent_tool(target="spark", message="ping", agent=agent)
)
assert result2["status"] == "sent"
assert calls[1]["command"].startswith("hermes peer dm spark < ")
def test_named_profile_sender_prefix(tmp_path, monkeypatch):
"""A named-profile bot signs with its own handle, not @hermes."""
calls = _capture_spawn(monkeypatch)
home = _managed_home(tmp_path, teammates=("researcher", "coder"))
profile_home = home / "profiles" / "coder"
agent = _FakeAgent(profile_home, title="Bot Chat")
result = json.loads(
bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent)
)
assert result["status"] == "sent"
dm_file = calls[0]["command"].rsplit(" ", 1)[-1].strip("'")
assert Path(dm_file).read_text(encoding="utf-8").startswith(
"Message from 🤖 coder (@coder): "
)
def test_spawn_failure_reports_error(tmp_path, monkeypatch):
home = _managed_home(tmp_path)
agent = _FakeAgent(home, title="Bot Chat")
import tools.terminal_tool as terminal_tool_module
def boom(command, **kwargs):
raise RuntimeError("spawn failed")
monkeypatch.setattr(terminal_tool_module, "terminal_tool", boom)
result = json.loads(
bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent)
)
assert "error" in result
assert "could not be started" in result["error"]

View File

@@ -51,8 +51,8 @@ def test_emits_for_default_when_any_profile_is_managed(tmp_path):
# default's callable alias is @hermes, never @default
assert "@hermes" in section
assert "@default" not in section
assert "`researcher`" in section
assert "hermes profile list" in section
assert "@researcher" in section
assert "message_agent" in section
def test_emits_for_named_profile_with_own_handle(tmp_path):
@@ -62,9 +62,36 @@ def test_emits_for_named_profile_with_own_handle(tmp_path):
section = bot_mode_probe.get_bot_mode_protocol_section(profile_dir)
assert "@coder" in section
# teammate list excludes self, includes default
assert "`default`" in section
assert "`coder`" not in section.split("Teammates at session start:")[1]
# teammate roster excludes self, includes default (as @hermes)
roster_block = section.split("Your teammates")[1]
assert "`@hermes`" in roster_block
assert "`@coder`" not in roster_block
def test_roster_lines_carry_roles(tmp_path):
"""Bots must know WHO to message: the roster carries title/description."""
import textwrap as _tw
home = tmp_path / ".hermes"
home.mkdir()
d = home / "profiles" / "researcher"
d.mkdir(parents=True)
(d / "profile.yaml").write_text(
_tw.dedent(
"""\
description: Deep research and literature review
ui_meta:
hermes-bots:
title: Research Buddy
"""
),
encoding="utf-8",
)
section = bot_mode_probe.get_bot_mode_protocol_section(home)
assert "`@researcher`" in section
assert "Research Buddy" in section
assert "Deep research and literature review" in section
def test_silent_when_soul_already_carries_protocol(tmp_path):
@@ -233,7 +260,8 @@ def test_peer_paragraph_lists_registered_peers(tmp_path):
)
section = bot_mode_probe.get_bot_mode_protocol_section(home)
assert "hermes peer dm" in section
assert "message_agent" in section
assert '"<peer>/<agent-name>"' in section
assert "`homelab`" in section and "`spark`" in section
assert "hermes peer list" in section

384
tools/bot_mode_dm.py Normal file
View File

@@ -0,0 +1,384 @@
"""Bot Mode agent-to-agent DM tool — ``message_agent``.
A structured, Bot-Chat-only tool that lets a Bot Mode agent message a
teammate agent (another Hermes profile on this install, or an agent on a
registered peer gateway) WITHOUT hand-assembling shell commands.
Why this exists (Aug 2026): the Bot Mode teammate protocol taught agents to
DM each other via a prompt-injected ``hermes -p <bot> chat ...`` shellout.
That transport works, but the *invocation* was fragile — quoting traps
(#91339/#91304), temp-file choreography, dead-profile races — and the
Desktop's remote-mention path forwarded raw user text verbatim (#91397).
``message_agent`` replaces the invocation with a real tool call: the message
is a parameter, the target is validated against the live roster, the
attribution prefix is applied server-side, and the reply arrives through the
existing background-process notification path (fire-and-forget, never
blocks the sender's turn).
Containment contract (MUST hold — reviewers check all three):
- The tool schema is injected ONLY into a bot's canonical "Bot Chat"
session on Bot-Mode-managed installs — the exact same gate as the
protocol section in ``tools/bot_mode_probe.py``. It is NOT registered in
the global tool registry, is NOT part of any toolset, and never appears
in CLI sessions, ordinary gateway chats, group-room member sessions
(titled "Group: …"), cron agents, or subagents.
- Dispatch is title-gated again at execution time (defense in depth): a
forged call from a session that shouldn't have the tool returns a
structured error instead of delivering.
- Everything here is additive. The legacy protocol transports
(``hermes -p`` / ``hermes peer dm``) keep working for older prompts.
The transports themselves are unchanged and proven:
- local teammate → ``hermes -p <name> chat --in ~ -c "Bot Chat"
--create-if-missing -Q --query-file <tmp>`` (one turn, reply on stdout)
- peer teammate → ``hermes peer dm <peer>[/<name>] < <tmp>``
Both run through ``terminal_tool(background=True, notify_on_complete=True)``
so the reply lands as a completion notification on the sender's NEXT turn —
the same wake shape every Bot Mode agent already knows.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shlex
import tempfile
import time
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
MESSAGE_AGENT_TOOL_NAME = "message_agent"
# Message body cap — generous for real work products, small enough that a
# runaway paste can't turn one DM into a context bomb on the recipient.
MESSAGE_MAX_CHARS = 16000
_PEER_TARGET_RE = re.compile(r"^([a-z0-9][a-z0-9_-]{0,63})/([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})$")
_LOCAL_TARGET_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
def message_agent_tool_schema() -> dict:
"""OpenAI-format schema for ``message_agent`` (injected, not registered)."""
return {
"type": "function",
"function": {
"name": MESSAGE_AGENT_TOOL_NAME,
"description": (
"Send a message to ANOTHER agent (teammate) on this install, or to an "
"agent on a registered peer gateway. This is FIRE-AND-FORGET and "
"asynchronous, like texting: it validates the target against the live "
"roster, delivers your message into that agent's own Bot Chat with your "
"attribution automatically prefixed, and returns immediately with a "
"delivery acknowledgement. It does NOT return their reply and you must "
"not wait or poll for one — send it, finish your turn, and the reply "
"arrives later as a background-process completion notification that "
"wakes you. COMPOSE the message yourself: write what YOU want to say to "
"that agent (lead with the point; include the concrete ask or result). "
"Never paste the user's words verbatim — paraphrase the actionable "
"substance, and keep private 1:1 chat content private. Message one "
"clearly relevant teammate when it genuinely helps the user's goal; "
"don't fan out to several agents unless the user explicitly asked. "
"Use the teammate roster in your system prompt (names + roles) to pick "
"the right recipient; targets: a teammate name (e.g. 'researcher'), or "
"'<peer>/<agent>' for an agent on a registered peer gateway "
"(e.g. 'spark/researcher', or just '<peer>' for the peer's main agent)."
),
"parameters": {
"type": "object",
"properties": {
"target": {
"type": "string",
"description": (
"Who to message: a teammate profile name from your roster "
"('researcher', 'hermes' for the default agent), or "
"'<peer>' / '<peer>/<agent>' for a registered peer gateway."
),
},
"message": {
"type": "string",
"description": (
"The message YOU composed for that agent (max "
f"{MESSAGE_MAX_CHARS} chars). Do not include the "
"'Message from …' prefix — it is added automatically."
),
},
},
"required": ["target", "message"],
},
},
}
def ensure_message_agent_tool(agent: Any) -> bool:
"""Inject the ``message_agent`` schema into a Bot Chat agent's tool list.
Called once per turn from the conversation loop. Idempotent and
deterministic for the life of a session: the gate (canonical Bot Chat
title on a Bot-Mode-managed install) is stable from the session's first
turn, so the tool list is byte-identical across turns — prompt-cache
safe. Every non-Bot-Chat session fails the gate on every turn and never
sees the schema. Never raises.
"""
try:
if not getattr(agent, "_bot_mode_protocol", True):
return False
tools = getattr(agent, "tools", None)
if tools:
for tool in tools:
if (
isinstance(tool, dict)
and tool.get("function", {}).get("name") == MESSAGE_AGENT_TOOL_NAME
):
return True
from tools.bot_mode_probe import BOT_CHAT_TITLE, get_bot_mode_protocol_section
if _session_title(agent) != BOT_CHAT_TITLE:
return False
if not get_bot_mode_protocol_section(_agent_home(agent)):
return False
if agent.tools is None:
agent.tools = []
agent.tools.append(message_agent_tool_schema())
valid = getattr(agent, "valid_tool_names", None)
if isinstance(valid, set):
valid.add(MESSAGE_AGENT_TOOL_NAME)
return True
except Exception: # pragma: no cover — must never break a turn
logger.debug("ensure_message_agent_tool failed", exc_info=True)
return False
# ── roster resolution ────────────────────────────────────────────────────────
def _hermes_root(home: Path) -> Path:
if home.parent.name == "profiles":
return home.parent.parent
return home
def _self_profile_name(home: Path) -> str:
if home.parent.name == "profiles":
return home.name
return "default"
def _local_roster(root: Path) -> list[str]:
"""Profile names on this install: default + every named profile."""
names = ["default"]
try:
profiles = root / "profiles"
if profiles.is_dir():
for child in sorted(profiles.iterdir()):
if child.is_dir():
names.append(child.name)
except Exception:
pass
return names
def _peers(root: Path) -> list[str]:
try:
from tools.bot_mode_probe import _peers as _probe_peers
return _probe_peers(root)
except Exception:
return []
def _handle(name: str) -> str:
return "hermes" if name == "default" else name
def _resolve_local_name(target: str, roster: list[str]) -> Optional[str]:
"""Map a target handle to a profile name ('hermes' → 'default')."""
want = target.strip()
if not want:
return None
if want.lower() == "hermes":
return "default" if "default" in roster else None
for name in roster:
if name.lower() == want.lower():
return name
return None
# ── the tool ─────────────────────────────────────────────────────────────────
def _err(message: str, *, roster: list[str] | None = None, peers: list[str] | None = None) -> str:
payload: dict[str, Any] = {"error": message}
if roster is not None:
payload["teammates"] = roster
if peers is not None:
payload["peers"] = peers
return json.dumps(payload)
def message_agent_tool(
target: str = "",
message: str = "",
task_id: Optional[str] = None,
agent: Any = None,
) -> str:
"""Deliver ``message`` to ``target``'s Bot Chat. Returns a JSON ack/error.
``agent`` is the calling AIAgent (threaded by the executor) — used for
the Bot Chat gate, the sender identity, and the session key so the
spawned transport is tracked against the right session.
"""
# ── defense-in-depth gate: only a canonical Bot Chat may deliver ──
home = _agent_home(agent)
try:
from tools.bot_mode_probe import BOT_CHAT_TITLE, get_bot_mode_protocol_section
title = _session_title(agent)
if title != BOT_CHAT_TITLE:
return _err(
"message_agent is only available in a Bot Mode 'Bot Chat' session. "
"This session is not one; do not retry."
)
if not get_bot_mode_protocol_section(home):
return _err(
"This install is not Bot-Mode-managed (no bot roster); "
"message_agent is unavailable. Do not retry."
)
except Exception as exc: # pragma: no cover — defensive
return _err(f"Bot Mode gate check failed: {exc}")
root = _hermes_root(Path(home))
me = _self_profile_name(Path(home))
roster = _local_roster(root)
peers = _peers(root)
teammates = [_handle(n) for n in roster if n != me]
body = str(message or "").strip()
if not body:
return _err("message is required — compose what you want to say to that agent.")
if len(body) > MESSAGE_MAX_CHARS:
return _err(
f"message too long ({len(body)} chars > {MESSAGE_MAX_CHARS}). "
"Send the essentials; share large content as a file path instead."
)
raw_target = str(target or "").strip().lstrip("@")
if not raw_target:
return _err("target is required.", roster=teammates, peers=peers)
sender_handle = _handle(me)
prefix = f"Message from 🤖 {sender_handle} (@{sender_handle}): "
# ── peer target: '<peer>/<agent>' or a bare registered peer name ──
peer_match = _PEER_TARGET_RE.match(raw_target)
bare_peer = raw_target.lower() if raw_target.lower() in peers else None
if peer_match or bare_peer:
peer_name = peer_match.group(1) if peer_match else bare_peer
peer_profile = peer_match.group(2) if peer_match else None
if peer_name not in peers:
return _err(
f"No registered peer named '{peer_name}'.", roster=teammates, peers=peers
)
dm_target = f"{peer_name}/{peer_profile}" if peer_profile else peer_name
command = f"hermes peer dm {shlex.quote(dm_target)} < {shlex.quote(_write_dm_file(prefix + body))}"
label = f"@{peer_profile or peer_name} on peer '{peer_name}'"
return _spawn_delivery(command, label, task_id=task_id, agent=agent)
# ── local teammate ──
if not _LOCAL_TARGET_RE.match(raw_target):
return _err(f"Invalid target: {raw_target!r}.", roster=teammates, peers=peers)
resolved = _resolve_local_name(raw_target, roster)
if resolved is None:
return _err(
f"No teammate named '{raw_target}' on this install. "
"Pick a name from the roster (roles are listed in your system prompt).",
roster=teammates,
peers=peers,
)
if resolved == me:
return _err("You can't message yourself. Pick a teammate from the roster.")
dm_file = _write_dm_file(prefix + body)
command = (
f"hermes -p {shlex.quote(resolved)} chat --in ~ -c \"Bot Chat\" "
f"--create-if-missing -Q --query-file {shlex.quote(dm_file)}"
)
return _spawn_delivery(command, f"@{_handle(resolved)}", task_id=task_id, agent=agent)
def _write_dm_file(content: str) -> str:
"""The message rides a temp file — never inline shell text."""
fd, path = tempfile.mkstemp(prefix="hermes-dm-", suffix=".txt", text=True)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
return path
def _spawn_delivery(command: str, label: str, *, task_id: Optional[str], agent: Any) -> str:
"""Run the delivery command tracked + background, notify on completion."""
try:
from tools.terminal_tool import terminal_tool
raw = terminal_tool(
command,
background=True,
notify_on_complete=True,
task_id=task_id,
)
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
parsed = {}
proc_id = parsed.get("session_id") or ""
if parsed.get("error"):
return _err(f"Delivery to {label} failed to start: {parsed['error']}")
return json.dumps(
{
"status": "sent",
"to": label,
"detail": (
f"Message dispatched to {label}. This is asynchronous — do NOT wait "
"or poll. Finish your turn now; when the delivery completes, its "
"notification carries the reply — relay it then, attributed to "
"that agent."
),
**({"process_id": proc_id} if proc_id else {}),
"sent_at": int(time.time()),
}
)
except Exception as exc:
logger.error("message_agent delivery spawn failed: %s", exc, exc_info=True)
return _err(f"Delivery to {label} could not be started: {exc}")
# ── agent-context helpers (mirror system_prompt.py's resolution) ─────────────
def _agent_home(agent: Any) -> str:
"""The calling agent's OWN home (session-db derived), not ambient env."""
try:
sdb = getattr(agent, "_session_db", None)
db_path = getattr(sdb, "db_path", None)
if db_path:
return str(Path(db_path).parent)
except Exception:
pass
return os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")
def _session_title(agent: Any) -> str:
title = str(getattr(agent, "_session_title_hint", "") or "").strip()
if title:
return title
try:
sdb = getattr(agent, "_session_db", None)
sid = getattr(agent, "session_id", None)
if sdb and sid:
return str(sdb.get_session_title(sid) or "").strip()
except Exception:
pass
return ""

View File

@@ -105,6 +105,51 @@ def _handle(name: str) -> str:
return "hermes" if name == "default" else name
def _profile_role(profile_dir: Path) -> str:
"""A teammate's role line: Bot Mode title, else profile description.
The ui_meta['hermes-bots'].title is the name the user gave the bot in
Bot Mode; profile.yaml's description is the profile's stated purpose.
Either one tells a teammate WHO to message for a given job. Bounded and
single-line; empty when neither exists. Never raises.
"""
meta = profile_dir / "profile.yaml"
try:
if not meta.is_file():
return ""
raw = meta.read_text(encoding="utf-8", errors="replace")
import yaml
data = yaml.safe_load(raw)
if not isinstance(data, dict):
return ""
parts = []
ui_meta = data.get("ui_meta")
if isinstance(ui_meta, dict) and isinstance(ui_meta.get("hermes-bots"), dict):
title = str(ui_meta["hermes-bots"].get("title") or "").strip()
if title:
parts.append(title)
description = str(data.get("description") or "").strip()
if description:
parts.append(description)
line = " — ".join(parts)
return " ".join(line.split())[:160]
except Exception:
return ""
def _roster_lines(root: Path, me: str) -> list[str]:
"""One '- `@handle` — role' line per teammate (excluding ``me``)."""
lines = []
for name, profile_dir in _roster(root):
if name == me:
continue
role = _profile_role(profile_dir)
handle = _handle(name)
lines.append(f"- `@{handle}`" + (f" — {role}" if role else ""))
return lines
def _peers(root: Path) -> list[str]:
"""Registered peer gateway names (``hermes peer``), for the protocol text.
@@ -137,15 +182,10 @@ def _peer_paragraph(root: Path) -> str:
listed = ", ".join(f"`{p}`" for p in peers)
return (
"\n\nTeammates on OTHER machines: this install also has peer gateways "
f"registered ({listed}). Message an agent on a peer the same way — write "
"the message to a temp file first, then pipe it on stdin (same terminal-"
"tool pattern: background=true, notify_on_complete=true; the reply prints "
"on stdout when it completes):\n"
"```\n"
"hermes peer dm <peer>/<agent-name> < /tmp/dm.txt\n"
"```\n"
"Use `<peer>` alone for the peer's main agent. Run `hermes peer list` "
"for the live peer list."
f"registered ({listed}). Message an agent on a peer the same way — "
'message_agent with target "<peer>/<agent-name>" (or "<peer>" alone '
"for the peer's main agent). Run `hermes peer list` for the live "
"peer list."
)
@@ -164,26 +204,32 @@ def _build_section(home: Path) -> str:
return ""
handle = _handle(me)
teammates = ", ".join(f"`{n}`" for n, _d in roster if n != me) or "(none yet)"
roster_block = "\n".join(_roster_lines(root, me)) or "- (no teammates yet)"
return (
f"{_PROTOCOL_HEADING}\n"
"This install runs Bot Mode: each Hermes profile is an agent teammate with "
'one canonical "Bot Chat" conversation. To message a teammate: write the '
"message to a temp file with the file tool FIRST (never inline it into the "
"command — quotes truncate it and $( ) would execute), then run on the "
"terminal tool (background=true, notify_on_complete=true) and finish your "
"turn — the reply arrives later as a new message:\n"
"```\n"
f'hermes -p <agent-name> chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file /tmp/dm.txt\n'
"```\n"
f'The file must open with the "Message from 🤖 {handle} (@{handle}):" prefix so they '
"know who is talking. When YOU receive a message with that prefix, you are "
"being messaged by a teammate agent — address them (not the user) and reply "
"concisely. When the user says \"ask <name>\" or \"tell <name> ...\", that is a "
"handoff: message that agent, wait for the reply, and report back, saying "
"which agent it came from. Run `hermes profile list` for the LIVE teammate "
f"list before a handoff. Teammates at session start: {teammates}."
'one canonical "Bot Chat" conversation, and you have the `message_agent` '
"tool to DM any of them. It is FIRE-AND-FORGET: it delivers your message "
"with your attribution prefixed automatically and returns an acknowledgement "
"immediately — it never returns the reply. Send it, finish your turn, and "
"the reply arrives later as a background-process completion notification "
"that wakes you; relay it to the user then, attributed to that agent. "
"COMPOSE every message yourself — say what YOU need from that agent; never "
"forward the user's words verbatim, and never reveal private 1:1 chat "
"content. When the user says \"ask <name>\" or \"tell <name> ...\", that is "
"a handoff: pick the right teammate from the roster below, message them "
"with message_agent, and report back naming which agent replied. Message "
"ONE clearly relevant teammate; don't fan out to several unless the user "
"explicitly asked.\n"
f'When YOU receive a "Message from 🤖 <name> (@<handle>):" message, a '
"teammate agent is talking to you (not the user): address them, reply "
"concisely via message_agent to their handle, and if it is a pure FYI "
"with nothing to add, staying silent is fine — never ping-pong "
"acknowledgements.\n"
f"You are `@{handle}`. Your teammates (live roster; roles from their "
"profiles):\n"
f"{roster_block}"
+ _peer_paragraph(root)
)
@@ -275,8 +321,18 @@ def capability_fingerprint(home: str | os.PathLike | None = None) -> str:
try:
root = _hermes_root(resolved)
surface["roster"] = sorted(n for n, d in _roster(root) if _is_bot_managed(d))
# Roles are part of the messaging surface: renaming a bot or editing
# a profile description must refresh eternal Bot Chat prompts so the
# roster block teammates pick recipients from stays current.
surface["roster_roles"] = sorted(
f"{n}:{_profile_role(d)}" for n, d in _roster(root)
)
except Exception:
surface["roster"] = []
# Protocol-text version salt: bumping this refreshes every eternal Bot
# Chat prompt ONCE so existing bots adopt a new protocol section (e.g.
# the v2 message_agent tool replacing the shellout instructions).
surface["protocol_version"] = 2
try:
# Peer gateways are part of the messaging surface: registering one
# must refresh eternal Bot Chat prompts so the cross-machine DM

View File

@@ -97,7 +97,7 @@ Bots message each other with attribution, and you can hand work off from any cha
- **@mentions** — type `@researcher have a look at this` in any chat and the active Bot hands the message off, waits for the reply, and reports back. Mention names are validated against the live roster, so an email address or an unknown `@` passes through untouched.
- **Renamed Bots keep their tags in sync** — give a Bot a friendly name (the pencil in its chat header, or `hermes profile rename`) and it becomes taggable by that name: a Bot titled *Research Buddy* answers to `@research-buddy` (and `@researchbuddy`), in regular chats and in group rooms alike. The composer's `@` autocomplete offers the renamed tag and also matches when you type the old profile name, which keeps resolving too.
- **@mentions across machines** — mentioning a Bot that lives on another registered connection (use its `@name-device` handle when names collide) delivers over the Connections registry in the background: the active Bot stays on this device, the desktop routes the message to the recipient's machine, and the reply is relayed back attributed to that agent. Your window's gateway never switches.
- **Direct messages** — a Bot reaches a teammate's Bot Chat through the standard CLI: it writes the message to a temp file (opening with the `Message from 🤖 <sender> (@<sender>):` prefix), then runs `hermes -p <bot> chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file <file>`. The file transport means nothing is shell-interpreted — quotes, `$(...)`, and backticks in the message arrive verbatim. The receiving Bot sees the message the next time it runs and knows how to reply, because the messaging protocol is part of its Bot Chat system prompt.
- **Direct messages** — every Bot Chat carries the `message_agent` tool: a Bot messages a teammate by calling `message_agent(target="researcher", message="…")`. The tool validates the target against the live roster, prefixes the sender's `Message from 🤖 <sender> (@<sender>):` attribution automatically, and delivers into the teammate's canonical Bot Chat. Delivery is **fire-and-forget**: the sender gets an acknowledgement, finishes its turn, and the reply arrives later as a background completion notification. The message travels as a real parameter (nothing shell-interpreted — quotes, `$(...)`, and backticks arrive verbatim), and the Bot composes its own message rather than forwarding your words. The teammate roster — names **and roles** from each profile's title/description — is part of every Bot Chat's system prompt, so Bots know who does what before choosing a recipient. The tool exists **only** in canonical Bot Chat sessions on Bot-Mode-managed installs; regular chats, group-room member sessions, and CLI sessions never see it.
The backend teaches each Bot's canonical Bot Chat session the messaging protocol automatically at prompt-build time — including when a teammate opens it headlessly from the CLI. Only the canonical Bot Chat gets the protocol section; your regular sessions and your SOUL.md stay untouched. This is controlled by `agent.bot_mode_protocol` in `config.yaml` (default: on):
@@ -123,7 +123,7 @@ hermes peer dm spark/researcher < /tmp/dm.txt # named profile on a multiplexed
`hermes peer dm` delivers into the remote agent's canonical Bot Chat over the peer's existing API server, runs one agent turn there, and prints the reply on stdout — the exact cross-machine twin of the local `hermes -p <bot> chat` command.
Once a peer is registered, the messaging protocol taught to every Bot Chat (`agent.bot_mode_protocol`) automatically includes the peer roster and the `hermes peer dm` pattern — so **your bots learn on their own** that teammates exist on other machines and how to reach them. Registering or removing a peer refreshes each Bot Chat's protocol on its next message (capability epoch).
Once a peer is registered, the messaging protocol taught to every Bot Chat (`agent.bot_mode_protocol`) automatically includes the peer roster, and `message_agent` accepts peer targets directly — `message_agent(target="spark/researcher", …)`, or `target="spark"` for the peer's main agent — so **your bots learn on their own** that teammates exist on other machines and how to reach them. Registering or removing a peer refreshes each Bot Chat's protocol on its next message (capability epoch).
Requirements: the peer machine runs the `api_server` gateway platform with a strong `API_SERVER_KEY`; reachability is your network's business (LAN, Tailscale, VPN). The key is a credential and lives in `~/.hermes/.env` as `HERMES_PEER_<NAME>_KEY`; peer names/URLs live in `config.yaml` under `bot_peers`.