Files
hermes-agent/gateway/slash_commands_goals.py
Teknium 45a6101f36 fix(gateway): secondary-profile send_message, notices and /loop wakeups go out via their own bot
Under gateway.multiplex_profiles a turn running for a secondary profile P resolved
its live adapter by bare platform from runner.adapters — the DEFAULT profile's map —
so P's send_message tool calls (send/react/media on slack, matrix, wecom, buzz, ntfy,
every plugin platform), its "Gateway shutting down/restarted" and /update notices,
its /loop wakeups, and its Discord bot's unauthorized-slash operator alert all left
through the default bot (Telegram DMs landed in the user's chat with the other bot).

Every such door now resolves through the profile-aware, fail-closed resolver already
used by the inbound reply path (authz_mixin: _adapters_for_profile /
_authorization_adapter / _adapter_for_source): P's own adapter, or None → a clear
error, never the default bot.

- tools/send_message_senders.py::_live_adapter — resolve via
  runner._authorization_adapter(platform, get_active_profile_name()); shared by
  _send_via_adapter, _handle_react/unreact, media sends, matrix E2EE fast path and
  the WeCom standalone sender.
- gateway/authz_mixin.py — extract _adapters_for_profile (the whole map, for relay-
  aware resolve_delivery_transport callers); _authorization_adapter reuses it.
- gateway/run_shutdown.py — shutdown/restart notice for a running session uses the
  session's source transport / agent:<profile>: key lane, never self.adapters.
- gateway/slash_commands.py + run_notifications.py — /restart and /update markers
  persist `profile`; the restart notice, update result and update prompt resolve the
  requester's own adapter (legacy markers fall back to the session_key lane).
  /goal, /heartbeat, /approve, /deny confirmations use the source's own transport.
- gateway/slash_commands_goals.py + run_goals.py — /loop persists `profile` in its
  route; the wakeup watcher scans every served profile's store under its own scope
  (same shape as _handoff_watcher) and fires through that profile's adapter map.
- plugins/platforms/discord/adapter.py::_notify_unauthorized_slash — alert stays in
  the owning profile (its adapters and its home channels).
- plugins/platforms/wecom/adapter.py::_standalone_send — via _live_adapter.
- docs: website/docs/user-guide/multi-profile-gateways.md (outbound identity).

Tests (red on base): tests/tools/test_send_message_multiplex_profile_adapter.py,
tests/gateway/test_multiplex_notice_egress_profile_adapter.py.

Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
2026-09-10 18:17:22 -07:00

304 lines
14 KiB
Python

"""Autonomy-loop gateway commands: /goal, /subgoal, /heartbeat, /loop, /refine, /review.
Bound onto ``GatewayRunner`` through ``GatewaySlashCommandsMixin``."""
from __future__ import annotations
import logging
from agent.i18n import t
from gateway.platforms.event import MessageEvent, MessageType
# Log-record parity with gateway/run.py and the origin module.
logger = logging.getLogger("gateway.run")
def _plural(n: int, noun: str) -> str:
return f"{n} {noun}{'s' if n != 1 else ''}"
def _quiet_bool(fn) -> bool:
try:
return bool(fn())
except Exception:
return False
def _mgr_call(prefix: str, fn, *args, errors=(RuntimeError, ValueError)):
"""``(result, None)`` from ``fn(*args)``, or ``(None, "<prefix>: <exc>")`` on a manager error."""
try:
return fn(*args), None
except errors as exc:
return None, f"{prefix}: {exc}"
class GatewayGoalCommandsMixin:
"""Autonomy-loop gateway commands: /goal, /subgoal, /heartbeat, /loop, /refine, /review."""
async def _handle_goal_command(self, event: MessageEvent) -> str:
from hermes_cli.goal_command import dispatch_goal_command
from hermes_cli.goals import last_user_message_from_db
mgr, _session_entry = await self._get_goal_manager_for_event(event)
if mgr is None:
return t("gateway.goal.unavailable")
def authorize_gate():
if not self._resume_caller_is_admin(event.source):
return ("⛔ /goal gate add requires an explicitly configured "
"gateway admin (allow_admin_from for DMs, "
"group_allow_admin_from for groups).")
return None
def dispatch():
return dispatch_goal_command(
mgr, event.get_command_args() or "", authorize_gate=authorize_gate,
render=lambda key, default, **values: t(key, **values),
last_user_message=last_user_message_from_db(getattr(mgr, "session_id", None)),
)
# Drafting resolves profile-scoped credentials. Keep ContextVars across the
# executor hop; manager I/O must also stay off the messaging event loop.
result = await self._run_in_executor_with_context(dispatch)
if result.clear_pending:
self._clear_goal_continuations(event, result.clear_pending)
if result.prompt:
self._enqueue_goal_turn(event, result.prompt, label="command enqueue", kickoff=result.kickoff)
return result.output
def _clear_goal_continuations(self, event: MessageEvent, verb: str) -> None:
try:
adapter, quick_key = self._adapter_and_key_for(event)
if adapter and quick_key:
self._clear_goal_pending_continuations(quick_key, adapter)
except Exception as exc:
logger.debug("goal %s: pending continuation cleanup failed: %s", verb, exc)
def _enqueue_goal_turn(
self, event: MessageEvent, text: str, *, label: str, kickoff: bool
) -> None:
"""Enqueue *text* as the next turn through the adapter FIFO (the post-turn judge's path).
A kickoff keeps the triggering message id / channel prompt; a resume continuation carries
none. Best-effort: failures only logged.
"""
try:
adapter, quick_key = self._adapter_and_key_for(event)
if text and adapter and quick_key:
turn = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=event.source,
message_id=event.message_id if kickoff else None,
channel_prompt=event.channel_prompt if kickoff else None,
)
self._enqueue_fifo(quick_key, turn, adapter)
except Exception as exc:
logger.debug("goal %s failed: %s", label, exc)
async def _handle_heartbeat_command(self, event: MessageEvent) -> str:
"""Handle /heartbeat (mirror of the CLI handler): the session's one recurring re-entry
prompt. The gateway-wide poller injects due heartbeats through the adapter FIFO as
ordinary user turns, so alternation and caching hold."""
from hermes_cli.heartbeat import parse_interval, format_interval, MIN_INTERVAL_SECONDS
args = (event.get_command_args() or "").strip()
lower = args.lower()
mgr, _session_entry = await self._get_heartbeat_manager_for_event(event)
if mgr is None:
return "Heartbeats unavailable (no session)."
quick_key = self._session_key_for_source(event.source) if event.source else None
def _watch():
if quick_key and event.source is not None:
self._register_heartbeat_watch(quick_key, event.source, mgr.session_id)
if not args or lower == "status":
return mgr.status_line()
if lower == "pause":
state = mgr.pause()
return f"⏸ Heartbeat paused: {state.prompt}" if state else "No heartbeat set."
if lower == "resume":
state = mgr.resume()
if state is None:
return "No heartbeat to resume."
_watch()
return f"▶ Heartbeat resumed (every {format_interval(state.interval_seconds)}): {state.prompt}"
if lower in {"clear", "stop", "off"}:
had = mgr.clear()
if quick_key:
self._unregister_heartbeat_watch(quick_key)
return "✓ Heartbeat cleared." if had else "No heartbeat set."
# Set: `/heartbeat every 10m <prompt>` (also accepts `10m <prompt>`).
tokens = args.split(None, 2)
interval, prompt = None, ""
if tokens[0].lower() == "every" and len(tokens) >= 2:
interval = parse_interval(f"every {tokens[1]}")
prompt = tokens[2] if len(tokens) > 2 else ""
else:
interval = parse_interval(tokens[0])
prompt = args[len(tokens[0]):].strip() if interval and interval > 0 else ""
if interval is None:
return (
"Usage: /heartbeat every <interval> <prompt> (e.g. /heartbeat every 10m Check CI)\n"
"Also: /heartbeat status | pause | resume | clear"
)
if interval < 0:
return f"Interval too small — minimum is {MIN_INTERVAL_SECONDS}s."
if not prompt.strip():
return "Usage: /heartbeat every <interval> <prompt> — the prompt is required."
state, err = _mgr_call("Invalid heartbeat", mgr.set, prompt, interval, errors=(ValueError,))
if err:
return err
_watch()
return (
f"♥ Heartbeat set (every {format_interval(state.interval_seconds)}): {state.prompt}\n"
"Fires as a normal turn whenever this session is idle and the interval has "
"elapsed. Lives while the gateway runs — use `hermes cron` for durable schedules."
)
def _idle_cached_agent_or_error(self, event: MessageEvent, verb: str):
"""``(session_key, cached_agent, None)`` for /refine and /review, or ``(_, _, error_text)``:
both need a cached agent from a completed turn and refuse while a run is in flight."""
quick_key = self._session_key_for_source(event.source) if event.source else None
if not quick_key:
return None, None, f"{verb.capitalize()} unavailable (no session)."
if quick_key in self._running_agents:
return quick_key, None, f"Agent is running — wait for the turn to finish, then /{verb}."
agent = self._cached_agent_for(quick_key)
if agent is None:
return quick_key, None, f"Nothing to {verb} yet — send a message first."
return quick_key, agent, None
async def _handle_refine_command(self, event: MessageEvent) -> str:
"""Handle /refine — run the memory/skill review fork on demand, in a daemon thread against a
snapshot of the cached AIAgent's conversation (live session and prompt cache untouched)."""
args = (event.get_command_args() or "").strip()
_quick_key, agent, error = self._idle_cached_agent_or_error(event, "refine")
if error:
return error
snapshot = list(getattr(agent, "_session_messages", None) or [])
if not snapshot:
return "Nothing to refine yet — the conversation is empty."
try:
agent._spawn_background_review(
messages_snapshot=snapshot, review_memory=True,
review_skills="skill_manage" in getattr(agent, "valid_tool_names", set()), focus=args or None,
)
except Exception as exc:
return f"/refine failed to start: {exc}"
tail = f" (focus: {args})" if args else ""
return (
f"⚗ Reviewing this conversation in the background{tail} — "
f"any memory/skill updates will be reported when done."
)
async def _handle_review_command(self, event: MessageEvent) -> str:
"""Handle /review — spawn an independent reviewer subagent. The approval session-key
contextvar is only bound during agent turns, so bind it explicitly here or the completion
event carries no gateway route and never re-enters this chat."""
args = (event.get_command_args() or "").strip()
quick_key, agent, error = self._idle_cached_agent_or_error(event, "review")
if error:
return error
snapshot = list(getattr(agent, "_session_messages", None) or [])
from tools.approval_context import reset_current_session_key, set_current_session_key
def _dispatch():
token = set_current_session_key(quick_key)
try:
from agent.review_engine import start_review
return start_review(agent, snapshot, args)
finally:
reset_current_session_key(token)
try:
# _run_in_executor_with_context, not a bare hop: the reviewer subagent is spawned from
# the worker and inherits its context; a bare hop would run it under the launch home.
result = await self._run_in_executor_with_context(_dispatch)
except ValueError as exc:
return str(exc)
except Exception as exc:
return f"/review failed to start: {exc}"
from agent.review_engine import format_dispatch_note
return format_dispatch_note(result, args)
async def _handle_subgoal_command(self, event: MessageEvent) -> str:
"""Handle /subgoal (mirror of the CLI handler): extra criteria appended to the active goal
mid-loop. They modify state read at the next turn boundary, so this is safe while the
agent is running."""
args = (event.get_command_args() or "").strip()
mgr, _session_entry = await self._get_goal_manager_for_event(event)
if mgr is None:
return t("gateway.goal.unavailable")
if not mgr.has_goal():
return "No active goal. Set one with /goal <text>."
if not args:
return f"{mgr.status_line()}\n{mgr.render_subgoals()}"
tokens = args.split(None, 1)
verb = tokens[0].lower()
rest = tokens[1].strip() if len(tokens) > 1 else ""
if verb == "remove":
if not rest:
return "Usage: /subgoal remove <n>"
try:
idx = int(rest.split()[0])
except ValueError:
return "/subgoal remove: <n> must be an integer (1-based index)."
removed, err = _mgr_call(
"/subgoal remove", mgr.remove_subgoal, idx, errors=(IndexError, RuntimeError)
)
return err or f"✓ Removed subgoal {idx}: {removed}"
if verb == "clear":
prev, err = _mgr_call("/subgoal clear", mgr.clear_subgoals, errors=(RuntimeError,))
if err:
return err
return f"✓ Cleared {_plural(prev, 'subgoal')}." if prev else "No subgoals to clear."
text, err = _mgr_call("/subgoal", mgr.add_subgoal, args)
if err:
return err
idx = len(mgr.state.subgoals) if mgr.state else 0
return f"✓ Added subgoal {idx}: {text}"
async def _handle_loop_command(self, event: MessageEvent) -> str:
"""Handle /loop — recurring in-session wakeups, via ``dispatch_loop_command`` (CLI mirror)."""
try:
from hermes_cli.loops import LoopManager, dispatch_loop_command, goal_blocks_loop_tick
except Exception as exc:
logger.debug("loops module unavailable: %s", exc)
return "Loops unavailable."
# Warm the SessionDB cache off-loop: a cold cache drops the first /loop write while the
# reply claims the loop was set (same class as the /goal false-ack fix).
await self._warm_goals_session_db("loop manager")
try:
session_entry = await self.async_session_store.get_or_create_session(event.source)
except Exception:
session_entry = None
sid = getattr(session_entry, "session_id", None) or ""
if not sid:
return "Loops unavailable (no active session)."
mgr = LoopManager(session_id=sid)
# New loops capture the event's routing so the idle loop-wakeup watcher can inject ticks
# here after a restart; best-effort, empty fields dropped. ``profile`` pins the wakeup to this
# session's own bot under multiplex (the watcher must never fire it through the default bot).
route: dict = {}
try:
src = event.source
if src is not None:
platform = getattr(src, "platform", "")
route = {"platform": platform.value if hasattr(platform, "value") else str(platform or "")}
for key in ("chat_id", "chat_type", "thread_id", "user_id", "user_name", "profile"):
route[key] = str(getattr(src, key, "") or "")
route = {k: v for k, v in route.items() if v}
except Exception:
route = {}
result = dispatch_loop_command(mgr, (event.get_command_args() or "").strip(), route=route)
output = result.get("output") or ""
if result.get("created") and _quiet_bool(lambda: goal_blocks_loop_tick(mgr.session_id)):
output += (
"\nNote: an active /goal is driving this session — loop "
"wakeups defer until the goal finishes, pauses, or parks."
)
return output