/ gateway / hooks.py
hooks.py
  1  """
  2  Event Hook System
  3  
  4  A lightweight event-driven system that fires handlers at key lifecycle points.
  5  Hooks are discovered from ~/.hermes/hooks/ directories, each containing:
  6    - HOOK.yaml  (metadata: name, description, events list)
  7    - handler.py (Python handler with async def handle(event_type, context))
  8  
  9  Events:
 10    - gateway:startup     -- Gateway process starts
 11    - session:start       -- New session created (first message of a new session)
 12    - session:end         -- Session ends (user ran /new or /reset)
 13    - session:reset       -- Session reset completed (new session entry created)
 14    - agent:start         -- Agent begins processing a message
 15    - agent:step          -- Each turn in the tool-calling loop
 16    - agent:end           -- Agent finishes processing
 17    - command:*           -- Any slash command executed (wildcard match)
 18  
 19  Errors in hooks are caught and logged but never block the main pipeline.
 20  """
 21  
 22  import asyncio
 23  import importlib.util
 24  import sys
 25  from typing import Any, Callable, Dict, List, Optional
 26  
 27  import yaml
 28  
 29  from hermes_cli.config import get_hermes_home
 30  
 31  
 32  HOOKS_DIR = get_hermes_home() / "hooks"
 33  
 34  
 35  class HookRegistry:
 36      """
 37      Discovers, loads, and fires event hooks.
 38  
 39      Usage:
 40          registry = HookRegistry()
 41          registry.discover_and_load()
 42          await registry.emit("agent:start", {"platform": "telegram", ...})
 43      """
 44  
 45      def __init__(self):
 46          # event_type -> [handler_fn, ...]
 47          self._handlers: Dict[str, List[Callable]] = {}
 48          self._loaded_hooks: List[dict] = []  # metadata for listing
 49  
 50      @property
 51      def loaded_hooks(self) -> List[dict]:
 52          """Return metadata about all loaded hooks."""
 53          return list(self._loaded_hooks)
 54  
 55      def _register_builtin_hooks(self) -> None:
 56          """Register built-in hooks that are always active.
 57  
 58          Currently empty — no shipped built-in hooks. Kept as the extension
 59          point for future always-on gateway hooks so they drop in without
 60          re-plumbing discover_and_load().
 61          """
 62          return
 63  
 64      def discover_and_load(self) -> None:
 65          """
 66          Scan the hooks directory for hook directories and load their handlers.
 67  
 68          Also registers built-in hooks that are always active.
 69  
 70          Each hook directory must contain:
 71            - HOOK.yaml with at least 'name' and 'events' keys
 72            - handler.py with a top-level 'handle' function (sync or async)
 73          """
 74          self._register_builtin_hooks()
 75  
 76          if not HOOKS_DIR.exists():
 77              return
 78  
 79          for hook_dir in sorted(HOOKS_DIR.iterdir()):
 80              if not hook_dir.is_dir():
 81                  continue
 82  
 83              manifest_path = hook_dir / "HOOK.yaml"
 84              handler_path = hook_dir / "handler.py"
 85  
 86              if not manifest_path.exists() or not handler_path.exists():
 87                  continue
 88  
 89              try:
 90                  manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
 91                  if not manifest or not isinstance(manifest, dict):
 92                      print(f"[hooks] Skipping {hook_dir.name}: invalid HOOK.yaml", flush=True)
 93                      continue
 94  
 95                  hook_name = manifest.get("name", hook_dir.name)
 96                  events = manifest.get("events", [])
 97                  if not events:
 98                      print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
 99                      continue
100  
101                  # Dynamically load the handler module.
102                  # Register in sys.modules BEFORE exec_module so Pydantic /
103                  # dataclasses / typing introspection can resolve forward
104                  # references (triggered by `from __future__ import annotations`
105                  # in the handler). Without this, a handler that declares a
106                  # Pydantic BaseModel for webhook/event payloads fails at first
107                  # dispatch with "TypeAdapter ... is not fully defined".
108                  module_name = f"hermes_hook_{hook_name}"
109                  spec = importlib.util.spec_from_file_location(
110                      module_name, handler_path
111                  )
112                  if spec is None or spec.loader is None:
113                      print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True)
114                      continue
115  
116                  module = importlib.util.module_from_spec(spec)
117                  sys.modules[module_name] = module
118                  try:
119                      spec.loader.exec_module(module)
120                  except Exception:
121                      sys.modules.pop(module_name, None)
122                      raise
123  
124                  handle_fn = getattr(module, "handle", None)
125                  if handle_fn is None:
126                      print(f"[hooks] Skipping {hook_name}: no 'handle' function found", flush=True)
127                      continue
128  
129                  # Register the handler for each declared event
130                  for event in events:
131                      self._handlers.setdefault(event, []).append(handle_fn)
132  
133                  self._loaded_hooks.append({
134                      "name": hook_name,
135                      "description": manifest.get("description", ""),
136                      "events": events,
137                      "path": str(hook_dir),
138                  })
139  
140                  print(f"[hooks] Loaded hook '{hook_name}' for events: {events}", flush=True)
141  
142              except Exception as e:
143                  print(f"[hooks] Error loading hook {hook_dir.name}: {e}", flush=True)
144  
145      def _resolve_handlers(self, event_type: str) -> List[Callable]:
146          """Return all handlers that should fire for ``event_type``.
147  
148          Exact matches fire first, followed by wildcard matches (e.g.
149          ``command:*`` matches ``command:reset``).
150          """
151          handlers = list(self._handlers.get(event_type, []))
152          if ":" in event_type:
153              base = event_type.split(":")[0]
154              wildcard_key = f"{base}:*"
155              handlers.extend(self._handlers.get(wildcard_key, []))
156          return handlers
157  
158      async def emit(self, event_type: str, context: Optional[Dict[str, Any]] = None) -> None:
159          """
160          Fire all handlers registered for an event, discarding return values.
161  
162          Supports wildcard matching: handlers registered for "command:*" will
163          fire for any "command:..." event. Handlers registered for a base type
164          like "agent" won't fire for "agent:start" -- only exact matches and
165          explicit wildcards.
166  
167          Args:
168              event_type: The event identifier (e.g. "agent:start").
169              context:    Optional dict with event-specific data.
170          """
171          if context is None:
172              context = {}
173  
174          for fn in self._resolve_handlers(event_type):
175              try:
176                  result = fn(event_type, context)
177                  # Support both sync and async handlers
178                  if asyncio.iscoroutine(result):
179                      await result
180              except Exception as e:
181                  print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
182  
183      async def emit_collect(
184          self,
185          event_type: str,
186          context: Optional[Dict[str, Any]] = None,
187      ) -> List[Any]:
188          """Fire handlers and return their non-None return values in order.
189  
190          Like :meth:`emit` but captures each handler's return value. Used for
191          decision-style hooks (e.g. ``command:<name>`` policies that want to
192          allow/deny/rewrite the command before normal dispatch).
193  
194          Exceptions from individual handlers are logged but do not abort the
195          remaining handlers.
196          """
197          if context is None:
198              context = {}
199  
200          results: List[Any] = []
201          for fn in self._resolve_handlers(event_type):
202              try:
203                  result = fn(event_type, context)
204                  if asyncio.iscoroutine(result):
205                      result = await result
206                  if result is not None:
207                      results.append(result)
208              except Exception as e:
209                  print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
210          return results