Files
hermes-agent/hermes_cli/plugins_injection.py
T
unsupportedpastels f7322744fc fix(plugins): send a key-less inject_message to the session the plugin is working for
Before the one-gateway runtime each classic CLI loaded its own plugins, so
ctx.inject_message(text) without a session key meant that CLI's own chat.
On a gateway session the classic CLI now loads no plugin code (PLG-7): the
only copy of a plugin is the gateway's (or its plugin host's), which serves
every session of its profile, so a key-less inject had no chat to target
and was refused.

The gateway now picks the target, in order:

1. The plugin is handling a local session's own call (its slash command, a
   hook fired for that session, a tool or hook in its turn): the message
   goes to that session. The key is read from the calling context, so this
   never guesses, however many sessions are open. pre_command deliveries
   now bind the session they fire for. Under plugins.isolation: host the
   forwarded ctx call already runs in its caller's context.
2. No session in the calling context (a timer, the plugin's own thread):
   the one local session of the plugin's profile launched by the classic
   CLI (frozen policy source "cli") with a viewer attached. TUI, Desktop,
   messaging and other profiles' sessions are never picked this way.
3. Otherwise (none, or several): refused. inject_message returns False,
   nothing is injected, and one warning per plugin and reason (rate
   limited) names the plugin and says to pass a session key.

A resolved target is committed through the keyed path's producer
admission: a queued input in that session's FIFO, run once and in order.
A key-less call made for a messaging chat keeps main's refusal; a keyed
inject is unchanged; allow_gateway_injection is still required.

The classic chat view's client-side inject slot (gateway_chat_inject) is
removed: nothing in that process calls it any more. Off the gateway the
in-process _cli_ref path is unchanged. The plugin guide describes the three
rules and recommends passing a session key.

Refs #134889 (PLG-7)
Refs #139523
Refs #139847
2026-10-16 19:39:48 -04:00

71 lines
3.6 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
# Neither: the live gateway picks the chat (gateway/plugin_keyless_inject.py) from the session this
# call works for, read here on the caller's own context; no gateway keeps main's refusal.
keyless = not session_key and origin is None
if keyless and not manager.has_gateway_message_injector:
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
# The manager's immutable home is the plugin's profile: the gateway creates an origin's session
# there or nowhere, and a keyed local session must belong to it (never the ambient HERMES_HOME
# of whatever thread called us).
if keyless:
from gateway.session_context import bound_session_key
target: dict[str, Any] = {"keyless": True, "calling_session_key": bound_session_key(),
"plugin_home": manager.home_path}
elif origin is None:
target = {"session_key": session_key, "plugin_home": manager.home_path}
else:
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