Files
hermes-agent/tests/gateway/test_plugin_origin_injection.py
Teknium 7b2ff7a7d4 feat(plugins): inject_message(origin=...) starts a gateway session in the plugin's own profile
`ctx.inject_message` could only reach an existing gateway session (`session_key`), so an
orchestrator plugin that opens a chat/thread itself (a bot-authored Discord forum post never
comes back as an inbound message) had no way to get an agent turn there.

`origin` takes the SessionSource.to_dict() shape. The gateway creates (or continues) that chat's
session through the adapter's normal message path, in the profile whose plugin manager loaded the
plugin, and the reply goes back to the origin chat:

- Profile: resolved from the manager's immutable home, never an ambient one. The origin may not
  name another profile, the gateway's own routing for that chat through that profile's adapter
  must land in the plugin's profile, and the profile needs its own connected adapter for the
  platform (another profile's bot is never borrowed). Refusals are synchronous False + one log line.
- Authorization: same current-allowlist check as the session_key path (no bypass for host code).
- Multiplex: handle_message runs inside the profile's runtime scope so the spawned turn AND the
  reply send after it inherit that profile's home and secrets (applies to the session_key path too,
  which used to send from the default loop context).
- allow_gateway_injection is read from the plugin's own profile config.
- Plugin/system turns (event.internal) skip the human first-contact notes (onboarding intro and
  the "no home channel" nudge), which were posted into the plugin's fresh thread.

Requested by canonicle-desk in #132609.

Closes #132609
2026-10-05 17:11:30 -07:00

201 lines
9.7 KiB
Python

"""``ctx.inject_message(origin=...)`` starts a gateway session in the plugin's OWN profile (#132609).
Real ``GatewayRunner`` + real ``BasePlatformAdapter`` ingress with a recording transport; only the
model call (``_run_agent_inner``) is replaced, and it records the home and secret scope the turn
actually ran under.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import hermes_yaml as yaml
import gateway.run as gateway_run
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
from gateway.profile_routing import parse_profile_routes
from gateway.run import GatewayRunner
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
PLUGIN = "desk"
class _RecordingAdapter(BasePlatformAdapter):
def __init__(self, label: str):
super().__init__(PlatformConfig(enabled=True, token=f"tok-{label}"), Platform.TELEGRAM)
self.label = label
self.sent: list[tuple[str, str]] = []
self.send_scopes: list[tuple] = [] # (home, PROBE_MARKER) each send ran under
self._running = True
async def connect(self, *, is_reconnect: bool = False) -> bool:
return True
async def disconnect(self) -> None:
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
from agent.secret_scope import get_secret
from hermes_constants import get_hermes_home
self.sent.append((str(chat_id), content))
self.send_scopes.append((get_hermes_home(), get_secret("PROBE_MARKER")))
return SendResult(success=True, message_id=str(len(self.sent)))
async def send_typing(self, chat_id, metadata=None):
return None
async def get_chat_info(self, chat_id):
return {"id": chat_id, "type": "group"}
def _write_home(home, marker: str) -> None:
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(yaml.safe_dump({
"plugins": {"entries": {PLUGIN: {"allow_gateway_injection": True}}},
}), encoding="utf-8")
(home / ".env").write_text(f"TELEGRAM_ALLOWED_USERS=u1\nPROBE_MARKER={marker}\n", encoding="utf-8")
def _context(home) -> tuple[PluginContext, PluginManager]:
manager = PluginManager(scope_key=str(home))
return PluginContext(PluginManifest(name=PLUGIN, key=PLUGIN, source="user"), manager), manager
def _origin(chat_id: str, **extra) -> dict:
return {"platform": "telegram", "chat_id": chat_id, "chat_type": "group", "thread_id": "7",
"user_id": "u1", "user_name": "Requester", **extra}
@pytest.fixture
def gateway(tmp_path, monkeypatch):
"""Multiplexed gateway serving ``default`` (bot P), ``alpha`` (its own bot A) and ``beta`` (its
own bot B); a route sends chat 999 on bot A to ``beta``."""
home = tmp_path / "hh"
homes = {"default": home, "alpha": home / "profiles" / "alpha", "beta": home / "profiles" / "beta"}
for name, profile_home in homes.items():
_write_home(profile_home, marker=name)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(gateway_run, "_hermes_home", home)
config = GatewayConfig(multiplex_profiles=True, sessions_dir=home / "sessions")
config.profile_routes = parse_profile_routes([
{"name": "beta-on-a", "platform": "telegram", "profile": "beta", "chat_id": "999", "bot_profile": "alpha"},
])
runner = GatewayRunner(config)
adapters = {name: _RecordingAdapter(name) for name in homes}
runner.adapters = {Platform.TELEGRAM: adapters["default"]}
runner._profile_adapters = {name: {Platform.TELEGRAM: adapters[name]} for name in ("alpha", "beta")}
for name, adapter in adapters.items():
adapter.gateway_runner = runner
if name != "default":
adapter.set_owner_profile(name)
adapter.set_message_handler(runner._make_profile_message_handler(name))
else:
adapter.set_message_handler(runner._handle_message)
turns = []
async def _fake_turn(message, context_prompt, history, source, session_id, **_kwargs):
from agent.secret_scope import get_secret
from hermes_constants import get_hermes_home
turns.append(SimpleNamespace(message=message, history=list(history), session_id=session_id,
home=get_hermes_home(), marker=get_secret("PROBE_MARKER")))
return {"final_response": f"reply-{len(turns)}", "messages": [], "tools": [],
"history_offset": len(history), "last_prompt_tokens": 0}
runner._run_agent_inner = _fake_turn
served = list(homes.items())
with patch("hermes_cli.profiles.profiles_to_serve", return_value=served), \
patch("hermes_cli.profiles.get_profile_dir", side_effect=lambda n: homes[n]), \
patch("hermes_cli.profiles.profile_exists", side_effect=lambda n: n in homes):
yield SimpleNamespace(runner=runner, homes=homes, adapters=adapters, turns=turns)
async def _publish(runner):
runner._gateway_loop = asyncio.get_running_loop()
runner._running = True
runner._install_plugin_message_injector()
async def _settle(runner, adapter, rounds: int = 200):
for _ in range(rounds):
await asyncio.sleep(0.01)
if not runner._background_tasks and not adapter._session_tasks and not adapter._active_sessions:
return
@pytest.mark.asyncio
async def test_origin_creates_session_in_own_profile_and_replies_to_origin(gateway):
"""No session at the origin: the plugin's profile gets a fresh session, the turn runs under that
profile's home and secrets, the reply goes back to the origin chat, and a human follow-up in the
same chat continues the SAME session (A -> B -> A: beta in between, alpha's scope comes back)."""
runner, alpha, beta = gateway.runner, gateway.adapters["alpha"], gateway.adapters["beta"]
ctx_alpha, mgr_alpha = _context(gateway.homes["alpha"])
ctx_beta, mgr_beta = _context(gateway.homes["beta"])
with patch("hermes_cli.plugins._known_plugin_managers", return_value=[mgr_alpha, mgr_beta]):
await _publish(runner)
assert ctx_alpha.inject_message("Kick off the review", origin=_origin("100")) is True
await _settle(runner, alpha)
assert ctx_beta.inject_message("Beta's own post", origin=_origin("200")) is True
await _settle(runner, beta)
assert ctx_alpha.inject_message("Second post", origin=_origin("101")) is True
await _settle(runner, alpha)
first, middle, last = gateway.turns
assert (first.home, first.marker) == (gateway.homes["alpha"], "alpha")
assert (middle.home, middle.marker) == (gateway.homes["beta"], "beta")
assert (last.home, last.marker) == (gateway.homes["alpha"], "alpha")
assert first.message.endswith("Kick off the review") and first.history == []
assert alpha.sent == [("100", "reply-1"), ("101", "reply-3")]
assert beta.sent == [("200", "reply-2")]
# The reply is sent after the handler returns: it must still run in the plugin's profile scope.
assert alpha.send_scopes == [(gateway.homes["alpha"], "alpha")] * 2
assert beta.send_scopes == [(gateway.homes["beta"], "beta")]
assert gateway.adapters["default"].sent == []
entry = runner.session_store._entries["agent:alpha:telegram:group:100:7"]
assert entry.session_id == first.session_id
assert not any(key.startswith(("agent:main:", "agent:beta:")) and ":100:" in key
for key in runner.session_store._entries)
# A human reply in the same chat keys into the plugin-created session. A secondary adapter's
# intake task runs inside its profile scope (it is spawned from connect() under that scope).
reply = alpha.build_source(chat_id="100", chat_type="group", thread_id="7", user_id="u1")
async with gateway_run._async_profile_runtime_scope(gateway.homes["alpha"]):
await alpha.handle_message(MessageEvent(text="thanks", message_type=MessageType.TEXT, source=reply))
await _settle(runner, alpha)
assert gateway.turns[-1].session_id == first.session_id
assert alpha.sent[-1] == ("100", "reply-4")
@pytest.mark.asyncio
async def test_origin_cannot_reach_another_profile(gateway):
"""A plugin in ``alpha`` cannot start a session in ``beta``: not by naming the profile, not
through a chat the gateway routes to ``beta``. Refusal is synchronous and nothing is created."""
runner, alpha = gateway.runner, gateway.adapters["alpha"]
ctx_alpha, mgr_alpha = _context(gateway.homes["alpha"])
with patch("hermes_cli.plugins._known_plugin_managers", return_value=[mgr_alpha]):
await _publish(runner)
assert ctx_alpha.inject_message("x", origin=_origin("100", profile="beta")) is False
assert ctx_alpha.inject_message("x", origin=_origin("999")) is False # routed to beta
assert ctx_alpha.inject_message("x", origin={"platform": "telegram"}) is False # malformed
await _settle(runner, alpha)
assert gateway.turns == [] and alpha.sent == [] and gateway.adapters["beta"].sent == []
assert runner.session_store._entries == {}
@pytest.mark.asyncio
async def test_origin_without_connected_adapter_returns_false(gateway):
runner = gateway.runner
ctx_alpha, mgr_alpha = _context(gateway.homes["alpha"])
runner._profile_adapters["alpha"] = {} # alpha's bot is down; never borrow default's
with patch("hermes_cli.plugins._known_plugin_managers", return_value=[mgr_alpha]):
await _publish(runner)
assert ctx_alpha.inject_message("x", origin=_origin("100")) is False
assert ctx_alpha.inject_message("x", origin={**_origin("100"), "platform": "discord"}) is False
assert runner._background_tasks == set()
assert gateway.adapters["default"].sent == []