Supervisor.py (12969B)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 | import os import json import numpy as np from langchain_openai import AzureChatOpenAI from langchain_core.messages import SystemMessage, HumanMessage from langgraph.graph import StateGraph, END from agent.tools.actuation import convert_targets_to_actions from agent.Marl.bandit import ContextualBandit from agent.Marl.strategies import STRATEGIES, NUM_ACTIONS from agent.Qdrant.Store import store_fmu from agent.sub_agents.water_and_atmospheric_dependencies.physics_engine import predict_outcome # 🛡️ GUARDRAILS from agent.guardrails.validation import validate_plan, detect_hard_violations, create_validation_report from dotenv import load_dotenv load_dotenv() # --- NEW TOOLS DEFINITION --- def check_cross_domain_conflicts(atmos, water): conflicts = [] # 1. Thermal Shock Check air_t = atmos.get('air_temp', 25) water_t = water.get('water_temp', 20) if abs(air_t - water_t) > 10: conflicts.append(f"CRITICAL: Thermal Shock Risk. Air ({air_t}C) and Water ({water_t}C) delta > 10C.") # 2. Transpiration vs Uptake Check # High VPD (Dry) + High EC (Salty) = Burn Risk rh = atmos.get('humidity', 60) ec = water.get('ec', 1.0) if rh < 50 and ec > 2.0: conflicts.append(f"STRESS: Low Humidity ({rh}%) + High EC ({ec}) will cause Tip Burn.") return conflicts def validate_hard_limits(plan): """ Validates plan against hard limits using guardrails. Returns list of violations. """ has_violations, violations = detect_hard_violations(plan) if has_violations: print(f"🚫 HARD LIMIT VIOLATIONS DETECTED:") for v in violations: print(f" {v}") # Also check for obvious physics conflicts additional_conflicts = [] if plan.get('air_temp', 25) - plan.get('water_temp', 20) > 10: additional_conflicts.append("⚠️ Thermal Shock Risk: Air/Water temp delta > 10°C") if plan.get('humidity', 60) < 40 and plan.get('ec', 1.0) > 2.5: additional_conflicts.append("⚠️ Burn Risk: Low humidity + high EC") if plan.get('humidity', 60) > 85: additional_conflicts.append("🚫 Mold Risk: Humidity > 85%") return violations + additional_conflicts # --- STATE DEFINITION --- from typing import TypedDict, Optional, Dict, Any, List class SupervisorState(TypedDict): # Inputs atmos_plan: Dict[str, Any] water_plan: Dict[str, Any] strategy_advice: str # Kept as advice, not law # Processing merged_plan: Dict[str, Any] review_notes: List[str] simulation_health: float # Output final_decision: str # "APPROVE" or "REJECT" critique: str # Feedback for sub-agents if Rejected API_KEY = os.environ.get("AZURE_OPENAI_API_KEY") ENDPOINT = os.environ.get("AZURE_OPENAI_ENDPOINT") DEPLOYMENT_NAME = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4.1") API_VERSION = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview") class SupervisorAgent: def __init__(self, researcher_agent=None): self.name = "Supervisor" self.bandit = ContextualBandit(n_actions=NUM_ACTIONS, feature_dim=519) if API_KEY and ENDPOINT: self.model = AzureChatOpenAI( azure_endpoint=ENDPOINT, api_key=API_KEY, api_version=API_VERSION, deployment_name=DEPLOYMENT_NAME, temperature=0.0 # Zero temp for strict judging ) self.app = self._build_graph() def _build_graph(self): workflow = StateGraph(SupervisorState) # 1. Merge: Combine the two JSONs workflow.add_node("merge", self.node_merge) # 2. Review: Run the 3 Tools (Conflicts, Limits, Physics) workflow.add_node("review", self.node_review) # 3. Judge: LLM decides if the issues are fatal workflow.add_node("judge", self.node_judge) # Flow workflow.set_entry_point("merge") workflow.add_edge("merge", "review") workflow.add_edge("review", "judge") workflow.add_edge("judge", END) return workflow.compile() # --- NODE FUNCTIONS --- def node_merge(self, state): print(" 🔗 Supervisor Merging Plans...") # Simple dictionary merge merged = {**state['atmos_plan'], **state['water_plan']} return {"merged_plan": merged} def node_review(self, state): print(" 🔍 Supervisor Running Unit Tests...") plan = state['merged_plan'] notes = [] # Tool 1: Conflict Check conflicts = check_cross_domain_conflicts(state['atmos_plan'], state['water_plan']) if conflicts: notes.extend(conflicts) # Tool 2: Limit Check limits = validate_hard_limits(plan) if limits: notes.extend(limits) # Tool 3: Physics Simulator # Comparing plan vs itself as a snapshot for now health = 100 if health < 90: notes.append(f"SIMULATION FAIL: Predicted health drops to {health}%. ") return {"review_notes": notes, "simulation_health": health} def node_judge(self, state): """ The LLM looks at the automated test results and makes the final call. """ print(" ⚖️ Supervisor Judging...") if not state['review_notes']: # No issues found by tools return {"final_decision": "APPROVE", "critique": "Plan looks solid."} # If issues exist, ask LLM if they are fatal or acceptable trade-offs prompt = f""" You are the Quality Assurance Supervisor. PROPOSED PLAN: {state['merged_plan']} AUTOMATED TEST FAILURES: {json.dumps(state['review_notes'], indent=2)} ADVISORY STRATEGY: {state['strategy_advice']} TASK: 1. BIAS: You should almost always APPROVE. 2. ONLY 'REJECT' if the plan is physically impossible or immediately fatal (e.g., pH < 3.0, Water Temp > 40°C). 3. IGNORE 'Simulation Fail' warnings if the Strategy justifies the extreme values (e.g., 'Flush' requires low EC). 4. Treat "Risk" warnings as acceptable trade-offs for the strategy. OUTPUT JSON: {{ "verdict": "APPROVE" or "REJECT", "critique": "Explanation..." }} """ print("Supervisor Prompt:\n", prompt) try: response = self.model.invoke([HumanMessage(content=prompt)]) content = response.content.replace("```json", "").replace("```", "").strip() result = json.loads(content) return { "final_decision": result.get("verdict", "REJECT"), "critique": result.get("critique", "Automated tests failed.") } except: # Default to reject if unsafe return {"final_decision": "REJECT", "critique": "Plan failed automated safety checks."} # --- ENTRY POINT --- def synthesize_plan(self, atmos_plan, water_plan, fmu, history, strategy_info): strategy_name, _, action_idx = strategy_info initial_state = { "atmos_plan": atmos_plan, "water_plan": water_plan, "strategy_advice": strategy_name, "merged_plan": {}, "review_notes": [], "simulation_health": 0.0, "final_decision": "", "critique": "" } result = self.app.invoke(initial_state) final_targets = result.get("merged_plan", {}) current_sensors = fmu.metadata.get('sensors', {}) # 🛡️ GUARDRAIL CHECK: Validate before executing print(f"[{self.name}] 🛡️ Running guardrail validation...") validation = validate_plan(final_targets) if validation["severity"] == "CRITICAL": print(create_validation_report(final_targets)) print(f"[{self.name}] ⚠️ CRITICAL VIOLATIONS - Clamping to bounds...") final_targets = validation["bounded_plan"] if validation["warnings"]: print(f"[{self.name}] ⚠️ Warnings: {', '.join(validation['warnings'])}") print(f"[{self.name}] ⚙️ Converting Targets to Actuator Commands...") sensor_vals = [ float(current_sensors.get("pH", 0.0)), float(current_sensors.get("EC", 0.0)), float(current_sensors.get("temp", 0.0)), float(current_sensors.get("humidity", 0.0)) ] if hasattr(fmu, 'vector') and len(fmu.vector) == 512: if isinstance(fmu.vector, list): fmu.vector.extend(sensor_vals) else: import numpy as np fmu.vector = np.concatenate((fmu.vector, sensor_vals)).tolist() # Calculate physical actions physical_action_obj = convert_targets_to_actions(current_sensors, final_targets) # Convert Pydantic model to Dict for JSON serialization final_payload = physical_action_obj.dict() # Log it print(f"[{self.name}] 🚜 Activating Hardware: {final_payload}") # Store in FMU fmu.metadata["action_taken"] = str(final_payload) fmu.metadata["bandit_action_id"] = action_idx fmu.metadata["strategic_intent"] = strategy_name if "image_b64" in fmu.metadata: del fmu.metadata["image_b64"] store_fmu(fmu) return final_payload # --- ADVISORY ONLY (Not Enforced) --- def get_strategic_goal(self, fmu): # (Same as before, but treated as advice now) sensors = fmu.metadata.get('sensors', {}) fmu_vector = fmu.vector vis_vec1 = np.array(fmu_vector) if isinstance(fmu_vector, list) else fmu_vector vis_vec = vis_vec1[:512] if len(vis_vec1) >= 512 else None if vis_vec is None or len(vis_vec) == 0: vis_vec = np.zeros(516) s_vec = np.array([ (float(sensors.get('pH', 6.0)) - 6.0) / 2.0, float(sensors.get('EC', 1.0)) / 3.0, float(sensors.get('temp', 25.0)) / 40.0 ]) context_vector = np.concatenate([vis_vec, s_vec]) # print("Context Vector for Bandit:", context_vector.shape) action_idx, _ = self.bandit.select_action(context_vector) strategy_name = STRATEGIES[action_idx] return strategy_name, "Advisory Only", int(action_idx) def learn_from_outcome(self, fmu, outcome_info): """ Bandit Learning: Update the model based on action outcome. Args: fmu: The FMU object containing metadata about the previous action outcome_info: Either: - Current plant health (0-100) from simulator, OR - Reward score (-1.0 to 1.0) from judge """ # Retrieve the action that was taken in the previous cycle prev_action_idx = fmu.metadata.get("bandit_action_id") if prev_action_idx is None: return # No previous action to learn from prev_action_idx = int(prev_action_idx) # Build the context vector (same as get_strategic_goal) sensors = fmu.metadata.get('sensors', {}) fmu_vector = fmu.vector vis_vec1 = np.array(fmu_vector) if isinstance(fmu_vector, list) else fmu_vector vis_vec = vis_vec1[:512] if len(vis_vec1) >= 512 else None if vis_vec is None or len(vis_vec) == 0: vis_vec = np.zeros(512) s_vec = np.array([ (float(sensors.get('pH', 6.0)) - 6.0) / 2.0, float(sensors.get('EC', 1.0)) / 3.0, float(sensors.get('temp', 25.0)) / 40.0 ]) context_vector = np.concatenate([vis_vec, s_vec]) # Handle reward: Can be health (0-100) or judge reward (-1 to 1) if isinstance(outcome_info, dict): reward = float(outcome_info.get("reward", 0.0)) else: # Assume it's health (0-100), convert to normalized reward health_val = float(outcome_info) if health_val >= 85: reward = 1.0 # Excellent elif health_val >= 70: reward = health_val / 100.0 # Good elif health_val >= 50: reward = (health_val / 100.0) * 0.5 # Mediocre else: reward = -1.0 # Terrible # Update the bandit model with this outcome self.bandit.update(context_vector, prev_action_idx, reward) strategy_name = STRATEGIES.get(prev_action_idx, "UNKNOWN") print(f"[{self.name}] 🧠 Bandit Learning: {strategy_name} (Action {prev_action_idx}) → Reward {reward:.2f}") # Save the updated model self.bandit.save() |