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