| """ |
| Hooks system for SpatialAgent. |
| |
| Provides extensible hook points throughout the agent execution lifecycle, |
| supporting both bash command hooks and prompt-based (LLM) hooks. |
| |
| Configuration is loaded from .spatialagent/settings.json |
| """ |
|
|
| import os |
| import json |
| import subprocess |
| import logging |
| import re |
| from typing import Dict, Any, List, Optional, Literal |
| from dataclasses import dataclass, field |
| from enum import Enum |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class HookEvent(str, Enum): |
| """Supported hook events throughout the agent lifecycle.""" |
| |
| START = "Start" |
| STOP = "Stop" |
|
|
| |
| PRE_PLAN = "PrePlan" |
| POST_PLAN = "PostPlan" |
|
|
| |
| PRE_ACT = "PreAct" |
| POST_ACT = "PostAct" |
|
|
| |
| PRE_TOOL_USE = "PreToolUse" |
| POST_TOOL_USE = "PostToolUse" |
|
|
| |
| PRE_ROUTE = "PreRoute" |
|
|
| |
| PRE_CONCLUSION = "PreConclusion" |
|
|
|
|
| @dataclass |
| class HookResult: |
| """Result from executing a hook.""" |
| decision: Literal["approve", "block"] = "approve" |
| reason: str = "" |
| output: str = "" |
| modified_args: Dict[str, Any] = field(default_factory=dict) |
|
|
| @property |
| def approved(self) -> bool: |
| return self.decision == "approve" |
|
|
| @property |
| def blocked(self) -> bool: |
| return self.decision == "block" |
|
|
|
|
| @dataclass |
| class HookDefinition: |
| """Definition of a single hook.""" |
| type: Literal["bash", "prompt"] |
| command: Optional[str] = None |
| prompt: Optional[str] = None |
| timeout: int = 30 |
| matcher: Optional[Dict[str, Any]] = None |
|
|
|
|
| class HooksManager: |
| """ |
| Manages hook loading, matching, and execution. |
| |
| Hooks are loaded from .spatialagent/settings.json and can be: |
| - bash: Execute a shell command |
| - prompt: Query an LLM for a decision |
| |
| Example settings.json: |
| { |
| "hooks": { |
| "PreAct": [ |
| { |
| "matcher": {"code_type": "bash"}, |
| "type": "prompt", |
| "prompt": "Review this bash command for safety: $CODE", |
| "timeout": 30 |
| } |
| ], |
| "Start": [ |
| { |
| "type": "bash", |
| "command": "echo 'Starting: $QUERY'" |
| } |
| ] |
| } |
| } |
| """ |
|
|
| def __init__(self, llm=None): |
| """ |
| Initialize HooksManager. |
| |
| Args: |
| llm: LLM instance for prompt-based hooks |
| """ |
| self.llm = llm |
| self.hooks: Dict[str, List[HookDefinition]] = {} |
| self.enabled = True |
|
|
| |
| self._load_config() |
|
|
| def _load_config(self): |
| """Load hooks configuration from .spatialagent/settings.json in SpatialAgent root directory.""" |
| |
| package_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| config_file = os.path.join(package_dir, ".spatialagent", "settings.json") |
|
|
| if not os.path.exists(config_file): |
| logger.debug("No .spatialagent/settings.json found, hooks disabled") |
| self.enabled = False |
| return |
|
|
| try: |
| with open(config_file, 'r') as f: |
| config = json.load(f) |
|
|
| hooks_config = config.get("hooks", {}) |
|
|
| for event_name, hook_list in hooks_config.items(): |
| |
| try: |
| event = HookEvent(event_name) |
| except ValueError: |
| logger.warning(f"Unknown hook event: {event_name}") |
| continue |
|
|
| self.hooks[event_name] = [] |
|
|
| for hook_def in hook_list: |
| |
| if "hooks" in hook_def: |
| for nested in hook_def["hooks"]: |
| self.hooks[event_name].append(self._parse_hook(nested, hook_def.get("matcher"))) |
| else: |
| self.hooks[event_name].append(self._parse_hook(hook_def)) |
|
|
| logger.info(f"Loaded hooks from {config_file}: {list(self.hooks.keys())}") |
|
|
| except json.JSONDecodeError as e: |
| logger.error(f"Invalid JSON in {config_file}: {e}") |
| self.enabled = False |
| except Exception as e: |
| logger.error(f"Error loading hooks config: {e}") |
| self.enabled = False |
|
|
| def _parse_hook(self, hook_def: Dict, parent_matcher: Optional[Dict] = None) -> HookDefinition: |
| """Parse a hook definition from config.""" |
| matcher = hook_def.get("matcher", parent_matcher) |
| return HookDefinition( |
| type=hook_def.get("type", "bash"), |
| command=hook_def.get("command"), |
| prompt=hook_def.get("prompt"), |
| timeout=hook_def.get("timeout", 30), |
| matcher=matcher |
| ) |
|
|
| def set_llm(self, llm): |
| """Set LLM for prompt-based hooks.""" |
| self.llm = llm |
|
|
| def _matches(self, hook: HookDefinition, context: Dict[str, Any]) -> bool: |
| """Check if hook matcher conditions are satisfied.""" |
| if not hook.matcher: |
| return True |
|
|
| for key, expected in hook.matcher.items(): |
| actual = context.get(key) |
| if actual is None: |
| return False |
|
|
| |
| if isinstance(expected, str) and isinstance(actual, str): |
| if not re.match(expected, actual): |
| return False |
| elif actual != expected: |
| return False |
|
|
| return True |
|
|
| def _substitute_variables(self, template: str, context: Dict[str, Any]) -> str: |
| """Substitute $VARIABLE placeholders in template with context values.""" |
| result = template |
|
|
| |
| for key, value in context.items(): |
| placeholder = f"${key.upper()}" |
| if placeholder in result: |
| result = result.replace(placeholder, str(value)) |
|
|
| |
| if "$ARGUMENTS" in result: |
| result = result.replace("$ARGUMENTS", json.dumps(context, default=str)) |
|
|
| return result |
|
|
| def _execute_bash_hook(self, hook: HookDefinition, context: Dict[str, Any]) -> HookResult: |
| """Execute a bash command hook.""" |
| if not hook.command: |
| return HookResult(decision="approve", reason="No command specified") |
|
|
| command = self._substitute_variables(hook.command, context) |
|
|
| try: |
| |
| env = os.environ.copy() |
| for key, value in context.items(): |
| env[key.upper()] = str(value) |
|
|
| result = subprocess.run( |
| command, |
| shell=True, |
| capture_output=True, |
| text=True, |
| timeout=hook.timeout, |
| env=env |
| ) |
|
|
| output = result.stdout + result.stderr |
|
|
| |
| try: |
| decision_match = re.search(r'\{[^{}]*"decision"[^{}]*\}', output) |
| if decision_match: |
| decision_json = json.loads(decision_match.group()) |
| return HookResult( |
| decision=decision_json.get("decision", "approve"), |
| reason=decision_json.get("reason", ""), |
| output=output |
| ) |
| except json.JSONDecodeError: |
| pass |
|
|
| |
| if result.returncode == 0: |
| return HookResult(decision="approve", output=output) |
| else: |
| return HookResult( |
| decision="block", |
| reason=f"Command exited with code {result.returncode}", |
| output=output |
| ) |
|
|
| except subprocess.TimeoutExpired: |
| return HookResult( |
| decision="block", |
| reason=f"Hook timed out after {hook.timeout}s" |
| ) |
| except Exception as e: |
| logger.error(f"Bash hook error: {e}") |
| return HookResult(decision="approve", reason=f"Hook error: {e}") |
|
|
| def _execute_prompt_hook(self, hook: HookDefinition, context: Dict[str, Any]) -> HookResult: |
| """Execute a prompt-based (LLM) hook.""" |
| if not self.llm: |
| logger.warning("Prompt hook requested but no LLM configured") |
| return HookResult(decision="approve", reason="No LLM for prompt hook") |
|
|
| if not hook.prompt: |
| return HookResult(decision="approve", reason="No prompt specified") |
|
|
| prompt = self._substitute_variables(hook.prompt, context) |
|
|
| try: |
| from langchain_core.messages import HumanMessage |
|
|
| response = self.llm.invoke([HumanMessage(content=prompt)]) |
| output = str(response.content) |
|
|
| |
| try: |
| |
| json_match = re.search(r'\{[^{}]*"decision"[^{}]*\}', output, re.DOTALL) |
| if json_match: |
| decision_json = json.loads(json_match.group()) |
| return HookResult( |
| decision=decision_json.get("decision", "approve"), |
| reason=decision_json.get("reason", ""), |
| output=output, |
| modified_args=decision_json.get("modified_args", {}) |
| ) |
| except json.JSONDecodeError: |
| pass |
|
|
| |
| output_lower = output.lower() |
| if "block" in output_lower or "deny" in output_lower or "reject" in output_lower: |
| return HookResult(decision="block", reason=output, output=output) |
|
|
| return HookResult(decision="approve", output=output) |
|
|
| except Exception as e: |
| logger.error(f"Prompt hook error: {e}") |
| return HookResult(decision="approve", reason=f"Hook error: {e}") |
|
|
| def execute(self, event: HookEvent, context: Dict[str, Any]) -> HookResult: |
| """ |
| Execute all hooks for a given event. |
| |
| Args: |
| event: The hook event type |
| context: Dictionary of contextual information available to hooks |
| |
| Returns: |
| HookResult with aggregated decision. If any hook blocks, result is blocked. |
| """ |
| if not self.enabled: |
| return HookResult(decision="approve") |
|
|
| event_name = event.value if isinstance(event, HookEvent) else event |
| hooks = self.hooks.get(event_name, []) |
|
|
| if not hooks: |
| return HookResult(decision="approve") |
|
|
| logger.debug(f"Executing {len(hooks)} hooks for {event_name}") |
|
|
| all_outputs = [] |
| modified_args = {} |
|
|
| for hook in hooks: |
| |
| if not self._matches(hook, context): |
| continue |
|
|
| |
| if hook.type == "bash": |
| result = self._execute_bash_hook(hook, context) |
| elif hook.type == "prompt": |
| result = self._execute_prompt_hook(hook, context) |
| else: |
| logger.warning(f"Unknown hook type: {hook.type}") |
| continue |
|
|
| all_outputs.append(result.output) |
| modified_args.update(result.modified_args) |
|
|
| |
| if result.blocked: |
| logger.info(f"Hook blocked {event_name}: {result.reason}") |
| return HookResult( |
| decision="block", |
| reason=result.reason, |
| output="\n".join(all_outputs), |
| modified_args=modified_args |
| ) |
|
|
| return HookResult( |
| decision="approve", |
| output="\n".join(all_outputs), |
| modified_args=modified_args |
| ) |
|
|
| def has_hooks(self, event: HookEvent) -> bool: |
| """Check if any hooks are registered for an event.""" |
| event_name = event.value if isinstance(event, HookEvent) else event |
| return bool(self.hooks.get(event_name)) |
|
|
|
|
| |
| _hooks_manager: Optional[HooksManager] = None |
|
|
|
|
| def get_hooks_manager() -> HooksManager: |
| """Get the global hooks manager, creating one if needed.""" |
| global _hooks_manager |
| if _hooks_manager is None: |
| _hooks_manager = HooksManager() |
| return _hooks_manager |
|
|
|
|
| def set_hooks_manager(manager: HooksManager): |
| """Set the global hooks manager.""" |
| global _hooks_manager |
| _hooks_manager = manager |
|
|
|
|
| def init_hooks(llm=None) -> HooksManager: |
| """Initialize and set the global hooks manager.""" |
| manager = HooksManager(llm=llm) |
| set_hooks_manager(manager) |
| return manager |
|
|