Phase 8 Production Hardening with complete governance infrastructure: - Vault integration with tiered policies (T0-T4) - DragonflyDB state management - SQLite audit ledger - Pipeline DSL and templates - Promotion/revocation engine - Checkpoint system for session persistence - Health manager and circuit breaker for fault tolerance - GitHub/Slack integrations - Architectural test pipeline with bug watcher, suggestion engine, council review - Multi-agent chaos testing framework Test Results: - Governance tests: 68/68 passing - E2E workflow: 16/16 passing - Phase 2 Vault: 14/14 passing - Integration tests: 27/27 passing Coverage: 57.6% average across 12 phases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
528 lines
18 KiB
Python
Executable File
528 lines
18 KiB
Python
Executable File
#!/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()
|