#!/usr/bin/env python3 """ LLM Planner Agent - Tier 0 Observer ==================================== A compliant agent that uses OpenRouter LLMs for planning tasks. Follows the Agent Foundation document constraints. Capabilities: - Read documentation and inventory from Vault - Generate implementation plans using LLM - Produce structured, auditable outputs - Log all actions to governance ledger """ import json import sqlite3 import subprocess import sys from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import Any, Optional from openai import OpenAI from pydantic import BaseModel, Field # ============================================================================= # Agent Metadata (Section 4 of Foundation Doc) # ============================================================================= AGENT_METADATA = { "agent_id": "llm-planner-001", "agent_role": "observer", "owner": "system", "version": "0.1.0", "tier": 0, "input_contract": "TaskRequest", "output_contract": "AgentOutput", "allowed_side_effects": [ "read_docs", "read_inventory", "read_logs", "generate_plan", "llm_inference" ], "forbidden_actions": [ "ssh", "create_vm", "modify_vm", "delete_vm", "run_ansible", "run_terraform", "write_secrets", "execute_shell", "modify_files" ], "confidence_reporting": True, "confidence_threshold": 0.7, } # ============================================================================= # Structured Types (Section 6 of Foundation Doc) # ============================================================================= class Decision(str, Enum): EXECUTE = "EXECUTE" SKIP = "SKIP" ESCALATE = "ESCALATE" INSUFFICIENT_INFORMATION = "INSUFFICIENT_INFORMATION" ERROR = "ERROR" class SideEffect(BaseModel): type: str target: str reversible: bool = True class ErrorInfo(BaseModel): type: str message: str triggering_input: str partial_progress: str recommended_action: str class AgentOutput(BaseModel): """Required output format per Foundation Doc Section 6""" agent_id: str version: str timestamp: str action: str decision: Decision confidence: float = Field(ge=0.0, le=1.0) assumptions: list[str] = [] dependencies: list[str] = [] side_effects: list[SideEffect] = [] notes_for_humans: str = "" error: Optional[ErrorInfo] = None # Extended fields for LLM agent llm_model: Optional[str] = None llm_response: Optional[str] = None plan: Optional[dict] = None class TaskRequest(BaseModel): """Input schema for task requests""" task_type: str # e.g., "generate_plan", "analyze", "summarize" description: str context: dict = {} constraints: list[str] = [] # ============================================================================= # Vault Integration # ============================================================================= class VaultClient: def __init__(self, addr: str = "https://127.0.0.1:8200"): self.addr = addr self.token = self._load_token() def _load_token(self) -> str: with open("/opt/vault/init-keys.json") as f: return json.load(f)["root_token"] def read_secret(self, path: str) -> dict: result = subprocess.run([ "curl", "-sk", "-H", f"X-Vault-Token: {self.token}", f"{self.addr}/v1/secret/data/{path}" ], capture_output=True, text=True) data = json.loads(result.stdout) if "data" in data and "data" in data["data"]: return data["data"]["data"] raise ValueError(f"Failed to read secret: {path}") def get_openrouter_key(self) -> str: return self.read_secret("api-keys/openrouter")["api_key"] def get_inventory(self) -> dict: return self.read_secret("inventory/proxmox") def get_docs(self, doc_name: str) -> dict: return self.read_secret(f"docs/{doc_name}") # ============================================================================= # Ledger Integration # ============================================================================= class Ledger: def __init__(self, db_path: str = "/opt/agent-governance/ledger/governance.db"): self.db_path = db_path def log_action(self, output: AgentOutput, success: bool): conn = sqlite3.connect(self.db_path) cursor = conn.cursor() cursor.execute(""" INSERT INTO agent_actions (timestamp, agent_id, agent_version, tier, action, decision, confidence, success, error_type, error_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( output.timestamp, output.agent_id, output.version, AGENT_METADATA["tier"], output.action, output.decision.value, output.confidence, 1 if success else 0, output.error.type if output.error else None, output.error.message if output.error else None )) # Update metrics cursor.execute(""" INSERT INTO agent_metrics (agent_id, current_tier, total_runs, last_active_at, compliant_runs, consecutive_compliant) VALUES (?, ?, 1, ?, ?, ?) ON CONFLICT(agent_id) DO UPDATE SET total_runs = total_runs + 1, compliant_runs = CASE WHEN ? = 1 THEN compliant_runs + 1 ELSE compliant_runs END, consecutive_compliant = CASE WHEN ? = 1 THEN consecutive_compliant + 1 ELSE 0 END, last_active_at = ?, updated_at = ? """, ( output.agent_id, AGENT_METADATA["tier"], output.timestamp, 1 if success else 0, 1 if success else 0, 1 if success else 0, 1 if success else 0, output.timestamp, output.timestamp )) conn.commit() conn.close() # ============================================================================= # LLM Planner Agent # ============================================================================= class LLMPlannerAgent: """ Tier 0 Observer Agent with LLM capabilities. Invariant: If the agent cannot explain why it took an action, that action is invalid. (Foundation Doc Section 1) """ def __init__(self, model: str = "anthropic/claude-3.5-haiku"): self.metadata = AGENT_METADATA self.model = model self.vault = VaultClient() self.ledger = Ledger() # Initialize OpenRouter client api_key = self.vault.get_openrouter_key() self.llm = OpenAI( base_url="https://openrouter.ai/api/v1", api_key=api_key ) print(f"[INIT] Agent {self.metadata['agent_id']} v{self.metadata['version']} initialized") print(f"[INIT] LLM Model: {self.model}") print(f"[INIT] Tier: {self.metadata['tier']} ({self.metadata['agent_role']})") def _now(self) -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _validate_action(self, action: str) -> bool: """Section 3.3: Bounded Authority - validate action is allowed""" if action in self.metadata["forbidden_actions"]: return False if action in self.metadata["allowed_side_effects"]: return True # Default deny for unknown actions return False def _create_error_output(self, action: str, error: ErrorInfo) -> AgentOutput: return AgentOutput( agent_id=self.metadata["agent_id"], version=self.metadata["version"], timestamp=self._now(), action=action, decision=Decision.ERROR, confidence=0.0, error=error, notes_for_humans="Action failed - see error details" ) def _check_confidence(self, confidence: float) -> bool: """Section 7: Confidence below threshold = no irreversible actions""" return confidence >= self.metadata["confidence_threshold"] def generate_plan(self, request: TaskRequest) -> AgentOutput: """Generate an implementation plan using LLM""" action = "generate_plan" # Validate action is allowed if not self._validate_action(action): error = ErrorInfo( type="FORBIDDEN_ACTION", message=f"Action '{action}' is not permitted for this agent", triggering_input=request.description, partial_progress="None", recommended_action="Escalate to higher tier agent" ) output = self._create_error_output(action, error) self.ledger.log_action(output, success=False) return output # Build context from Vault try: inventory = self.vault.get_inventory() context_info = f"Infrastructure: {inventory.get('cluster', 'unknown')} with pools: {inventory.get('pools', 'unknown')}" except Exception as e: context_info = "Infrastructure context unavailable" # Construct prompt system_prompt = f"""You are a Tier 0 Observer agent in an infrastructure automation system. Your role is to generate implementation plans - you CANNOT execute anything. Agent Constraints: - You can ONLY generate plans, not execute them - Plans must be reversible and include rollback steps - You must identify uncertainties and flag them - All assumptions must be explicitly stated Infrastructure Context: {context_info} Output your plan as JSON with this structure: {{ "title": "Plan title", "summary": "Brief summary", "confidence": 0.0-1.0, "assumptions": ["assumption 1", "assumption 2"], "uncertainties": ["uncertainty 1"], "steps": [ {{"step": 1, "action": "description", "reversible": true, "rollback": "how to undo"}} ], "estimated_tier_required": 0-4, "risks": ["risk 1"] }}""" user_prompt = f"""Task: {request.description} Additional Context: {json.dumps(request.context) if request.context else 'None'} Constraints: {', '.join(request.constraints) if request.constraints else 'None'} Generate a detailed implementation plan.""" # Call LLM try: response = self.llm.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], max_tokens=2000, temperature=0.3 # Lower temperature for more deterministic planning ) llm_response = response.choices[0].message.content # Try to parse the plan JSON try: # Extract JSON from response json_start = llm_response.find('{') json_end = llm_response.rfind('}') + 1 if json_start >= 0 and json_end > json_start: plan = json.loads(llm_response[json_start:json_end]) confidence = plan.get("confidence", 0.5) else: plan = {"raw_response": llm_response} confidence = 0.5 except json.JSONDecodeError: plan = {"raw_response": llm_response} confidence = 0.5 # Check confidence threshold decision = Decision.EXECUTE if self._check_confidence(confidence) else Decision.ESCALATE output = AgentOutput( agent_id=self.metadata["agent_id"], version=self.metadata["version"], timestamp=self._now(), action=action, decision=decision, confidence=confidence, assumptions=plan.get("assumptions", []), dependencies=[], side_effects=[ SideEffect(type="llm_inference", target=self.model, reversible=True) ], notes_for_humans=f"Plan generated. Estimated tier required: {plan.get('estimated_tier_required', 'unknown')}", llm_model=self.model, llm_response=llm_response[:500] + "..." if len(llm_response) > 500 else llm_response, plan=plan ) self.ledger.log_action(output, success=True) return output except Exception as e: error = ErrorInfo( type="LLM_ERROR", message=str(e), triggering_input=request.description, partial_progress="Context loaded, LLM call failed", recommended_action="Check API key and connectivity" ) output = self._create_error_output(action, error) self.ledger.log_action(output, success=False) return output def analyze(self, request: TaskRequest) -> AgentOutput: """Analyze a topic and provide insights""" action = "read_docs" # Analysis is a form of reading if not self._validate_action(action): error = ErrorInfo( type="FORBIDDEN_ACTION", message=f"Action '{action}' is not permitted", triggering_input=request.description, partial_progress="None", recommended_action="Check agent permissions" ) output = self._create_error_output(action, error) self.ledger.log_action(output, success=False) return output try: response = self.llm.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": "You are a technical analyst. Provide clear, structured analysis. Be explicit about uncertainties."}, {"role": "user", "content": request.description} ], max_tokens=1500, temperature=0.4 ) llm_response = response.choices[0].message.content output = AgentOutput( agent_id=self.metadata["agent_id"], version=self.metadata["version"], timestamp=self._now(), action=action, decision=Decision.EXECUTE, confidence=0.8, assumptions=["Analysis based on provided context only"], dependencies=[], side_effects=[ SideEffect(type="llm_inference", target=self.model, reversible=True) ], notes_for_humans="Analysis complete", llm_model=self.model, llm_response=llm_response ) self.ledger.log_action(output, success=True) return output except Exception as e: error = ErrorInfo( type="LLM_ERROR", message=str(e), triggering_input=request.description, partial_progress="None", recommended_action="Retry or check connectivity" ) output = self._create_error_output(action, error) self.ledger.log_action(output, success=False) return output def run(self, request: TaskRequest) -> AgentOutput: """Main entry point - routes to appropriate handler""" print(f"\n[TASK] Received: {request.task_type}") print(f"[TASK] Description: {request.description[:100]}...") handlers = { "generate_plan": self.generate_plan, "plan": self.generate_plan, "analyze": self.analyze, "analysis": self.analyze, } handler = handlers.get(request.task_type) if handler: return handler(request) # Unknown task type error = ErrorInfo( type="UNKNOWN_TASK_TYPE", message=f"Unknown task type: {request.task_type}", triggering_input=json.dumps(request.model_dump()), partial_progress="None", recommended_action=f"Use one of: {list(handlers.keys())}" ) output = self._create_error_output(request.task_type, error) self.ledger.log_action(output, success=False) return output # ============================================================================= # CLI Interface # ============================================================================= def main(): import argparse parser = argparse.ArgumentParser(description="LLM Planner Agent") parser.add_argument("task_type", choices=["plan", "analyze"], help="Type of task") parser.add_argument("description", help="Task description") parser.add_argument("--model", default="anthropic/claude-3.5-haiku", help="OpenRouter model") parser.add_argument("--json", action="store_true", help="Output raw JSON") args = parser.parse_args() agent = LLMPlannerAgent(model=args.model) request = TaskRequest( task_type=args.task_type, description=args.description ) output = agent.run(request) if args.json: print(output.model_dump_json(indent=2)) else: print("\n" + "="*60) print(f"Decision: {output.decision.value}") print(f"Confidence: {output.confidence}") print(f"Model: {output.llm_model}") print("="*60) if output.plan: print("\n📋 PLAN:") print(json.dumps(output.plan, indent=2)) elif output.llm_response: print("\n📝 RESPONSE:") print(output.llm_response) if output.error: print("\n❌ ERROR:") print(f" Type: {output.error.type}") print(f" Message: {output.error.message}") print(f" Recommendation: {output.error.recommended_action}") if output.assumptions: print("\n⚠️ ASSUMPTIONS:") for a in output.assumptions: print(f" - {a}") print("\n" + "="*60) if __name__ == "__main__": main()