Files
hermes-agent/hermes_cli/plugins_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

63 lines
3.0 KiB
Python

"""Host routing for ``PluginContext.inject_message``: classic CLI REPL, Ink TUI / desktop, messaging
gateway (existing ``session_key`` or a new session at ``origin``)."""
from __future__ import annotations
import logging
from collections.abc import Mapping
from typing import Any, Optional
logger = logging.getLogger("hermes_cli.plugins")
def inject_plugin_message(
ctx: Any, content: str, role: str, *, session_key: Optional[str],
origin: Optional[Mapping[str, Any]],
) -> bool:
manager = ctx._manager
msg = content if role == "user" else f"[{role}] {content}"
cli = manager._cli_ref
# An origin names a messaging chat; the local REPL is never that chat.
if cli is not None and origin is None:
queue_ = cli._interrupt_queue if getattr(cli, "_agent_running", False) else cli._pending_input
queue_.put(msg)
return True
if session_key and origin is not None:
logger.warning("inject_message: pass session_key or origin, not both")
return False
if not session_key and origin is None:
logger.warning("inject_message: gateway mode requires an existing session_key or an origin")
return False
if origin is not None and not isinstance(origin, Mapping):
logger.warning("inject_message: origin must be a mapping in the SessionSource.to_dict() shape")
return False
if not ctx._gateway_injection_allowed():
logger.warning("inject_message: gateway injection denied for plugin %s; set "
"plugins.entries.%s.allow_gateway_injection: true to allow it",
ctx.plugin_id, ctx.plugin_id)
return False
# TUI/desktop host is a different slot. It accepts only when it owns this
# session_key; a miss falls through so a co-resident messaging gateway
# still receives its own keys. An exception fails closed — do not also
# hand the same text to the gateway.
if session_key and manager.has_tui_message_injector:
try:
if manager.inject_tui_message(session_key=session_key, content=msg, plugin_id=ctx.plugin_id):
return True
except Exception:
logger.warning("inject_message: TUI scheduling failed for plugin %s", ctx.plugin_id, exc_info=True)
return False
if not manager.has_gateway_message_injector:
logger.warning("inject_message: no live gateway is available")
return False
if origin is None:
target: dict[str, Any] = {"session_key": session_key}
else:
# The manager's immutable home is the plugin's profile: the gateway creates the session
# there or nowhere (never the ambient HERMES_HOME of whatever thread called us).
target = {"origin": dict(origin), "plugin_home": manager.home_path}
try:
return bool(manager.inject_gateway_message(**target, content=msg, plugin_id=ctx.plugin_id))
except Exception:
logger.warning("inject_message: gateway scheduling failed for plugin %s", ctx.plugin_id, exc_info=True)
return False