jkkkkyuedtrt's picture
Upload 381 files
bd52a47 verified
Raw
History Blame Contribute Delete
13.2 kB
"""
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."""
# Agent lifecycle
START = "Start"
STOP = "Stop"
# Planning phase
PRE_PLAN = "PrePlan"
POST_PLAN = "PostPlan"
# Action/execution phase
PRE_ACT = "PreAct"
POST_ACT = "PostAct"
# Tool execution (Python/Bash)
PRE_TOOL_USE = "PreToolUse"
POST_TOOL_USE = "PostToolUse"
# Routing decisions
PRE_ROUTE = "PreRoute"
# Conclusion
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 # For bash hooks
prompt: Optional[str] = None # For prompt hooks
timeout: int = 30
matcher: Optional[Dict[str, Any]] = None # Conditions for when hook applies
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
# Load configuration
self._load_config()
def _load_config(self):
"""Load hooks configuration from .spatialagent/settings.json in SpatialAgent root directory."""
# Find the SpatialAgent package root (one level up from spatialagent package)
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():
# Validate event name
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:
# Handle nested hooks structure (like Claude's format)
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
# Support regex matching for string values
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
# Standard variable substitution
for key, value in context.items():
placeholder = f"${key.upper()}"
if placeholder in result:
result = result.replace(placeholder, str(value))
# Also handle $ARGUMENTS as JSON of all context
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:
# Set up environment with context variables
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
# Check for decision in output (JSON format)
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
# Default: approve if command succeeded
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)
# Parse JSON decision from response
try:
# Look for JSON in the response
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
# Fallback: look for keywords
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:
# Check matcher conditions
if not self._matches(hook, context):
continue
# Execute hook based on type
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 any hook blocks, stop and return block result
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))
# Global hooks manager instance (can be configured at startup)
_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