"""Autonomy-loop gateway commands: /goal, /subgoal, /heartbeat, /loop, /refine, /review. Split out of ``gateway/slash_commands.py``; bound onto ``GatewayRunner`` through ``GatewaySlashCommandsMixin``. Origin internals are imported lazily (``from gateway.slash_commands import ...``) inside the bodies to avoid the import cycle. """ from __future__ import annotations import logging from agent.i18n import t from gateway.platforms.base import MessageEvent, MessageType # Log-record parity with gateway/run.py and the origin module. logger = logging.getLogger("gateway.run") class GatewayGoalCommandsMixin: """Autonomy-loop gateway commands: /goal, /subgoal, /heartbeat, /loop, /refine, /review.""" async def _handle_goal_command(self, event: "MessageEvent") -> str: """Handle /goal for gateway platforms. Subcommands: status / pause / resume / clear. Setting a new goal queues the goal text as the next turn so the agent starts immediately; the post-turn continuation hook takes over after. """ args = (event.get_command_args() or "").strip() lower = args.lower() mgr, session_entry = await self._get_goal_manager_for_event(event) if mgr is None: return t("gateway.goal.unavailable") if not args or lower == "status": return mgr.status_line() if lower == "show": return f"{mgr.status_line()}\n{mgr.render_contract()}" if lower == "unwait": return "▶ Wait barrier cleared — goal loop resumes." if mgr.stop_waiting() else "No wait barrier set." if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() self._clear_goal_continuations(event, "clear") return t("gateway.goal_cleared") if had else t("gateway.no_active_goal") if lower == "pause": state = mgr.pause(reason="user-paused") if state is None: return t("gateway.goal.no_goal_set") self._clear_goal_continuations(event, "pause") return t("gateway.goal.paused", goal=state.goal) if lower == "resume": return self._goal_resume(mgr, event) # Verb-prefixed forms take the remainder as their argument. for verb, handler in (("wait ", self._goal_wait), ("gate ", self._goal_gate)): if lower == verb.strip() or lower.startswith(verb): return handler(mgr, args[len(verb) - 1:].strip(), event) return await self._goal_set(mgr, args, lower, event) 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 _goal_resume(self, mgr, event: MessageEvent) -> str: state = mgr.resume() if state is None: return t("gateway.goal.no_resume") # Resume must restart work, not just flip persisted state: enqueue the canonical # continuation through the adapter FIFO — the same path the post-turn judge uses — so # the next turn fires as soon as this reply is delivered. prompt = mgr.next_continuation_prompt() try: adapter, _quick_key = self._adapter_and_key_for(event) if prompt and adapter and _quick_key: cont_event = MessageEvent( text=prompt, message_type=MessageType.TEXT, source=event.source, message_id=None, channel_prompt=None, ) self._enqueue_fifo(_quick_key, cont_event, adapter) except Exception as exc: logger.debug("goal resume: continuation enqueue failed: %s", exc) return t("gateway.goal.resumed", goal=state.goal) @staticmethod def _goal_wait(mgr, wait_arg: str, event: MessageEvent) -> str: """/goal wait [reason] — park the loop on a background process.""" if not wait_arg: return "Usage: /goal wait [reason]" wtokens = wait_arg.split(None, 1) try: pid = int(wtokens[0]) except ValueError: return "/goal wait: must be an integer process id." reason = wtokens[1].strip() if len(wtokens) > 1 else "" try: mgr.wait_on(pid, reason=reason) except (RuntimeError, ValueError) as exc: return f"/goal wait: {exc}" rtxt = f" ({reason})" if reason else "" return f"⏳ Goal parked on pid {pid}{rtxt}. Loop pauses until it exits." def _goal_gate(self, mgr, gate_arg: str, event: MessageEvent) -> str: """/goal gate [list | add | remove | clear] — deterministic quality gates.""" gate_lower = gate_arg.lower() if not gate_arg or gate_lower == "list": return mgr.render_gates() if gate_lower.startswith("add "): # SECURITY: a gate is persisted and later executed with shell=True at every goal turn # boundary (run_gate), with no approval prompt. Letting an allowed but non-admin gateway # sender choose that string is authenticated RCE under the Hermes process account — and # with no admin list configured (the backward-compatible default) every allowed sender # is treated as unrestricted. Gate ONLY this shell-creating operation behind a real, # explicitly-configured admin (the same fail-closed check that guards cross-origin # /resume); list/remove/clear stay open so a non-admin can still recover. 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)." ) try: gate = mgr.add_gate(gate_arg[len("add"):].strip()) except (RuntimeError, ValueError) as exc: return f"/goal gate add: {exc}" return ( f"⚿ Gate added: $ {gate.command} " f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). " f"It must pass before the goal can complete." ) if gate_lower.startswith("remove ") or gate_lower.startswith("rm "): try: removed = mgr.remove_gate(int(gate_arg.split(None, 1)[1].strip())) except (RuntimeError, ValueError, IndexError) as exc: return f"/goal gate remove: {exc}" return f"✓ Gate removed: $ {removed}" if gate_lower == "clear": try: prev = mgr.clear_gates() except RuntimeError as exc: return f"/goal gate clear: {exc}" return f"✓ Cleared {prev} gate{'s' if prev != 1 else ''}." return "Usage: /goal gate [list | add | remove | clear]" async def _goal_set(self, mgr, args: str, lower: str, event: MessageEvent) -> str: """Set a new goal from free text, inline ``field: value`` contract lines, or ``draft ``.""" if lower.startswith("draft"): # Draft a structured completion contract, then set it. The aux LLM call is sync; # run it off the event loop. objective = args[len("draft"):].strip() if not objective: return "Usage: /goal draft " try: from hermes_cli.goals import draft_contract # _run_in_executor_with_context, not a bare hop: drafting a contract calls the # auxiliary LLM, whose provider/credential resolution reads the profile secret scope # — a contextvar that a default-executor hop drops, leaving it unscoped. contract = await self._run_in_executor_with_context(draft_contract, objective) except Exception as exc: logger.debug("goal draft failed: %s", exc) contract = None args = objective # the goal text is the objective else: # Inline `field: value` lines parse into a completion contract; the remaining prose is # the goal headline. Plain free-form goals (no such lines) behave exactly as before. from hermes_cli.goals import parse_contract headline, parsed = parse_contract(args) args = headline or args contract = parsed if not parsed.is_empty() else None try: state = mgr.set(args, contract=contract) except ValueError as exc: return t("gateway.goal.invalid", error=str(exc)) # Queue the goal text as an immediate first turn so the agent starts making progress. The # post-turn hook takes over after. adapter, _quick_key = self._adapter_and_key_for(event) if adapter and _quick_key: try: kickoff_event = MessageEvent( text=state.goal, message_type=MessageType.TEXT, source=event.source, message_id=event.message_id, channel_prompt=event.channel_prompt, ) self._enqueue_fifo(_quick_key, kickoff_event, adapter) except Exception as exc: logger.debug("goal kickoff enqueue failed: %s", exc) base = t("gateway.goal.set", budget=state.max_turns, goal=state.goal) if state.has_contract(): return f"{base}\nCompletion contract:\n{state.contract.render_block()}" if lower.startswith("draft"): # Drafting was requested but the aux model couldn't produce one. return f"{base}\n(Couldn't draft a contract — running as a free-form goal.)" return base async def _handle_heartbeat_command(self, event: "MessageEvent") -> str: """Handle /heartbeat for gateway platforms (mirror of CLI handler). Manages 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 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." if quick_key and event.source is not None: self._register_heartbeat_watch(quick_key, event.source, mgr.session_id) 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 ` (also accepts `10m `). tokens = args.split(None, 2) interval = None prompt = "" if tokens and tokens[0].lower() == "every" and len(tokens) >= 2: interval = parse_interval(f"every {tokens[1]}") prompt = tokens[2] if len(tokens) > 2 else "" elif tokens: 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 (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 — the prompt is required." try: state = mgr.set(prompt, interval) except ValueError as exc: return f"Invalid heartbeat: {exc}" if quick_key and event.source is not None: self._register_heartbeat_watch(quick_key, event.source, mgr.session_id) 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. Runs in a daemon thread against a snapshot of the cached AIAgent's conversation; the live session and prompt cache are untouched. Requires at least one completed turn. """ 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." review_skills = "skill_manage" in getattr(agent, "valid_tool_names", set()) try: agent._spawn_background_review( messages_snapshot=snapshot, review_memory=True, review_skills=review_skills, 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 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, # so a bare hop would run it under the launch home / no secret scope. 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 for gateway platforms (mirror of CLI handler). Subgoals are extra criteria appended to the active goal mid-loop. They modify state read at the next turn boundary, so this is safe to invoke 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 ." # No args → list current subgoals. 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 " try: idx = int(rest.split()[0]) except ValueError: return "/subgoal remove: must be an integer (1-based index)." try: removed = mgr.remove_subgoal(idx) except (IndexError, RuntimeError) as exc: return f"/subgoal remove: {exc}" return f"✓ Removed subgoal {idx}: {removed}" if verb == "clear": try: prev = mgr.clear_subgoals() except RuntimeError as exc: return f"/subgoal clear: {exc}" if prev: return f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}." return "No subgoals to clear." try: text = mgr.add_subgoal(args) except (ValueError, RuntimeError) as exc: return f"/subgoal: {exc}" idx = len(mgr.state.subgoals) if mgr.state else 0 return f"✓ Added subgoal {idx}: {text}" async def _get_loop_manager_for_event(self, event: "MessageEvent"): """Return a LoopManager bound to the session for this gateway event. Returns ``(manager, session_entry)``, or ``(None, None)`` when the loops module or session can't be loaded. Mirrors ``_get_goal_manager_for_event``. """ try: from hermes_cli.loops import LoopManager except Exception as exc: logger.debug("loop manager unavailable: %s", exc) return None, None # 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: return None, None sid = getattr(session_entry, "session_id", None) or "" if not sid: return None, None return LoopManager(session_id=sid), session_entry async def _handle_loop_command(self, event: "MessageEvent") -> str: """Handle /loop for gateway platforms — recurring in-session wakeups. Mirrors the CLI handler via ``dispatch_loop_command``. New loops capture the event's routing (platform/chat/thread) so the idle loop-wakeup watcher can inject ticks here after a restart. """ try: from hermes_cli.loops import dispatch_loop_command, goal_blocks_loop_tick except Exception as exc: logger.debug("loops module unavailable: %s", exc) return "Loops unavailable." mgr, _session_entry = await self._get_loop_manager_for_event(event) if mgr is None: return "Loops unavailable (no active session)." 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 ""), "chat_id": str(getattr(src, "chat_id", "") or ""), "chat_type": str(getattr(src, "chat_type", "") or ""), "thread_id": str(getattr(src, "thread_id", "") or ""), "user_id": str(getattr(src, "user_id", "") or ""), "user_name": str(getattr(src, "user_name", "") or ""), } route = {k: v for k, v in route.items() if v} except Exception: route = {} args = (event.get_command_args() or "").strip() result = dispatch_loop_command(mgr, args, route=route) output = result.get("output") or "" if result.get("created"): try: if 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." ) except Exception: pass return output