Files
hermes-agent/gateway/run_plugin_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

245 lines
12 KiB
Python

"""Plugin-triggered gateway turns (``PluginContext.inject_message``): the process-wide injector this
runner publishes, the thread-safe scheduler, and the dispatch through the adapter message path.
Two targets: an existing session (``session_key``; route and session id pinned from the store) or a
messaging chat (``origin``) whose session is created — or continued — through the normal session path
in the calling plugin's OWN profile. The origin path never chooses a profile: the gateway's routing
for that chat through that profile's adapter must already land in the plugin's profile, so a human
follow-up in the same chat keys into the same session."""
from __future__ import annotations
import asyncio
import concurrent.futures
import contextlib
import logging
import weakref
from pathlib import Path
from typing import Any, Mapping, Optional
from gateway.platforms.event import MessageEvent, MessageType
from gateway.session import SessionSource
logger = logging.getLogger("gateway.run")
class GatewayPluginInjectionMixin:
def _install_plugin_message_injector(self) -> None:
"""Publish this live gateway's plugin message scheduler process-wide."""
from hermes_cli.plugins import publish_gateway_message_host
publish_gateway_message_host(self, self._schedule_plugin_message_injection)
def _clear_plugin_message_injector(self) -> None:
"""Remove this runner's scheduler without clobbering a newer owner."""
from hermes_cli.plugins import clear_published_gateway_message_host
clear_published_gateway_message_host(self)
def _schedule_plugin_message_injection(
self, *, content: str, plugin_id: str, session_key: Optional[str] = None,
origin: Optional[Mapping[str, Any]] = None, plugin_home: Optional[Path] = None,
) -> bool:
"""Schedule a plugin-triggered turn on the live gateway loop (thread-safe).
With ``origin`` the profile, origin and adapter checks run here, synchronously, so the plugin
gets ``False`` for a chat it may not start; authorization is rechecked at dispatch."""
from gateway.run import safe_schedule_threadsafe
loop = getattr(self, "_gateway_loop", None)
if not getattr(self, "_running", False) or loop is None or loop.is_closed():
return False
if origin is not None:
source = self._plugin_origin_source(origin, plugin_home=plugin_home, plugin_id=plugin_id)
if source is None:
return False
target = f"origin={source.platform.value}/{source.chat_id}"
coro = self._dispatch_plugin_origin_injection(source=source, content=content, plugin_id=plugin_id)
else:
target = f"session={session_key}"
coro = self._dispatch_plugin_message_injection(
session_key=session_key, content=content, plugin_id=plugin_id,
)
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
if current_loop is loop:
try:
future = loop.create_task(coro)
except Exception:
coro.close()
logger.warning("Plugin message injection scheduling failed", exc_info=True)
return False
self._background_tasks.add(future)
future.add_done_callback(self._background_tasks.discard)
else:
future = safe_schedule_threadsafe(
coro, loop, logger=logger, log_message="Plugin message injection scheduling failed",
log_level=logging.WARNING,
)
if future is None:
return False
def _log_result(completed) -> None:
try:
if completed.result():
return
except (asyncio.CancelledError, concurrent.futures.CancelledError):
return
except Exception: # dispatch boundary: the plugin already got True; log, never raise
logger.warning("Plugin message injection failed: plugin=%s %s", plugin_id, target, exc_info=True)
return
logger.warning("Plugin message injection was not routed: plugin=%s %s", plugin_id, target)
future.add_done_callback(_log_result)
return True
def _plugin_injection_accepting(self) -> bool:
return getattr(self, "_running", False) and not getattr(self, "_draining", False)
def _plugin_injection_scope(self, source: SessionSource):
"""Multiplex: enter the source's profile runtime scope around ``handle_message`` so the
background task it spawns — the turn AND the reply send after the handler returns — inherits
that profile's home and secrets (this coroutine runs from the default loop context, not from
the profile adapter's own intake task)."""
if getattr(getattr(self, "config", None), "multiplex_profiles", False) is not True:
return contextlib.nullcontext()
from gateway.run import _async_profile_runtime_scope
return _async_profile_runtime_scope(self._resolve_profile_home_for_source(source))
def _plugin_injection_authorized(self, source: SessionSource, *, plugin_id: str, target: str) -> bool:
"""Current gateway authorization for the route the injected turn will run as. Host-side
plugin code is trusted to ask, never to widen access: a stored or supplied route must pass
today's allowlists / pairing / allow-all, and adapter-time grants (Discord roles) do not count."""
try:
authorized = self._is_user_authorized_for_source(source, allow_adapter_delegation=False)
except Exception:
logger.warning("Plugin message injection authorization check failed: plugin=%s %s",
plugin_id, target, exc_info=True)
return False
if not authorized:
logger.warning("Plugin message injection denied by current gateway authorization: plugin=%s %s",
plugin_id, target)
return authorized
async def _dispatch_plugin_message_injection(
self, *, session_key: str, content: str, plugin_id: str
) -> bool:
"""Route a plugin-triggered turn through the session's live adapter."""
if not self._plugin_injection_accepting():
return False
entry = await self.async_session_store.lookup_by_session_key(session_key)
if entry is None or entry.origin is None or not self._plugin_injection_accepting():
return False
from gateway.session_identity import replace_source
source = replace_source(self._restored_source(entry))
if not self._plugin_injection_authorized(source, plugin_id=plugin_id, target=f"session={session_key}"):
return False
adapter = self._delivery_adapter_for(source)
if adapter is None:
return False
async with self._plugin_injection_scope(source):
await adapter.handle_message(MessageEvent(
text=content, message_type=MessageType.TEXT, source=source, internal=True,
allow_gateway_control=False,
metadata={
"hermes_plugin_id": plugin_id, "hermes_plugin_injection": True,
"gateway_session_key": session_key, "gateway_session_id": entry.session_id,
"gateway_session_strict": True,
},
))
logger.info(
"Plugin message injection dispatched: plugin=%s session=%s session_id=%s",
plugin_id, session_key, entry.session_id,
)
return True
def _served_profile_for_home(self, home: Optional[Path]) -> Optional[str]:
"""Name of the profile this gateway serves at *home* (a plugin manager's immutable home), or
``None`` when this gateway does not serve it."""
if home is None:
return None
from hermes_constants import get_routing_process_hermes_home, hermes_home_key
key = hermes_home_key(home)
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
from gateway.run import _multiplex_profile_homes
try:
served = _multiplex_profile_homes(self.config)
except Exception:
logger.warning("Plugin message injection: served-profile set unavailable", exc_info=True)
return None
return next((name for name, served_home in served if hermes_home_key(served_home) == key), None)
if hermes_home_key(get_routing_process_hermes_home()) != key:
return None
return getattr(self, "_primary_profile_name", None) or self._active_profile_name()
def _plugin_origin_source(
self, origin: Mapping[str, Any], *, plugin_home: Optional[Path], plugin_id: str,
) -> Optional[SessionSource]:
"""The canonicalized source for a plugin-supplied *origin*, or ``None`` (logged) when the
plugin may not start a turn there: its profile is not served, the origin is malformed or
names another profile, that profile has no live adapter for the platform, or the gateway's
own routing for the chat lands in a different profile."""
def _refuse(reason: str, *args: Any) -> None:
logger.warning("Plugin message injection refused: plugin=%s " + reason, plugin_id, *args)
profile = self._served_profile_for_home(plugin_home)
if profile is None:
return _refuse("its profile home %s is not served by this gateway", plugin_home)
fields = dict(origin)
requested = fields.pop("profile", None)
if requested and requested != profile:
return _refuse("origin names profile %r; plugins may only start sessions in their own profile (%s)",
requested, profile)
try:
source = SessionSource.from_dict(fields)
except (KeyError, TypeError, ValueError) as exc:
return _refuse("invalid origin (%s)", exc)
where = f"{source.platform.value}/{source.chat_id}"
adapter = self._adapters_for_profile(profile).get(source.platform)
if adapter is None:
return _refuse("no connected %s adapter for profile %s", source.platform.value, profile)
# Same provenance build_source() stamps on a live inbound event from this adapter, so the
# identity (receiving bot, runtime profile, authorization home) resolves exactly as it would
# for a human message in this chat.
source._transport_adapter_ref = weakref.ref(adapter)
identity = self._canonicalize(source)
if identity is None:
return _refuse("origin %s does not resolve to a served profile", where)
if identity.runtime_profile != profile:
return _refuse("origin %s routes to profile %s, not the plugin's profile %s",
where, identity.runtime_profile, profile)
return source
async def _dispatch_plugin_origin_injection(
self, *, source: SessionSource, content: str, plugin_id: str
) -> bool:
"""Start (or continue) the session at a plugin-supplied origin through the adapter's normal
message path: ``get_or_create_session`` creates it, the reply goes back to that chat."""
target = f"origin={source.platform.value}/{source.chat_id}"
if not self._plugin_injection_accepting():
return False
adapter = self._delivery_adapter_for(source)
if adapter is None:
logger.warning("Plugin message injection: adapter for %s went away before dispatch", target)
return False
if not self._plugin_injection_authorized(source, plugin_id=plugin_id, target=target):
return False
session_key = self._session_key_for_source(source)
async with self._plugin_injection_scope(source):
await adapter.handle_message(MessageEvent(
text=content, message_type=MessageType.TEXT, source=source, internal=True,
allow_gateway_control=False,
# Non-strict: the key is pinned against topic recovery, the session may not exist yet.
metadata={
"hermes_plugin_id": plugin_id, "hermes_plugin_injection": True,
"gateway_session_key": session_key,
},
))
logger.info("Plugin message injection dispatched: plugin=%s %s session=%s", plugin_id, target, session_key)
return True