Files
hermes-agent/tests/tools/test_bot_mode_dm.py
T
Teknium e26d91dc11 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.
2026-08-21 15:23:51 -07:00

297 lines
10 KiB
Python

"""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"]