demeter

Autonomous Hydroponic Intelligence

agent/sub_agents/water_agent.py (6613B)


  1import os
  2from langchain_openai import AzureChatOpenAI
  3from langgraph.graph import StateGraph, END
  4
  5# Graph State & Nodes
  6from agent.sub_agents.water_and_atmospheric_dependencies.state import AgentState
  7from agent.sub_agents.water_and_atmospheric_dependencies.nodes import decide_node, simulate_node, finalize_node, execute_tools_node
  8
  9# Tools
 10from agent.sub_agents.water_and_atmospheric_dependencies.retrieval import ask_historian, ask_rag, diagnose_plant, ask_memory
 11from agent.sub_agents.water_and_atmospheric_dependencies.tools import check_ph_safety, web_search
 12
 13# 🛡️ GUARDRAILS
 14from agent.guardrails.validation import sanitize_input, validate_plan, create_validation_report
 15
 16# Configuration
 17API_KEY = os.environ.get("AZURE_OPENAI_API_KEY")
 18ENDPOINT = os.environ.get("AZURE_OPENAI_ENDPOINT")
 19DEPLOYMENT_NAME = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4.1")
 20API_VERSION = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview")
 21
 22# �️ === WATER & NUTRIENT SPECIALIST (FARM-ONLY MODE) ===
 23WATER_PROMPT = """
 24 === WATER & NUTRIENT SPECIALIST (FARM-ONLY MODE) ===
 25
 26YOUR ROLE:
 27You are an AI specialist controlling ONLY the water chemistry of a hydroponic farm.
 28Your SOLE purpose is to maintain optimal nutrient uptake through pH, EC, and water temperature.
 29
 30 HARD CONSTRAINTS (NON-NEGOTIABLE - YOU WILL FAIL IF YOU VIOLATE THESE):
 311. pH: MUST stay between 4.0 and 7.5 (failure if outside range)
 322. EC (Electrical Conductivity): MUST be between 0.1 and 3.0 dS/m (failure if outside range)
 333. Water Temperature: MUST be between 12°C and 28°C (failure if outside range)
 34
 35 OPTIMIZATION TARGETS (aim for these if possible, but NEVER violate hard constraints):
 36- pH: 5.5-6.5 (vegetables) or 6.0-7.0 (herbs) - NEVER shift >0.5 in one cycle
 37- EC: Crop-specific ranges within 0.1-3.0 bounds
 38- Water Temp: 20-24°C optimal (prevent root rot if >24°C)
 39
 40 CRITICAL SITUATION HANDLING:
 41- If plant is in critical condition (health < 50%), STAY SAFE within hard constraints
 42- Do NOT attempt aggressive nutrient corrections that violate bounds
 43- Conservative stable values within bounds are BETTER than aggressive out-of-bounds values
 44- The system will gradually improve through multiple safe cycles
 45
 46 CURRENT STATE:
 47Sensors: {sensors}
 48Strategy: {strategy}
 49Research: {research}
 50History: {history}
 51Simulation Feedback: {critique}
 52Visual Data: Available via 'diagnose_plant' tool if leaf yellowing/issues detected
 53
 54 OUTPUT REQUIREMENTS:
 55- Return ONLY valid JSON with exactly these keys: 'ph', 'ec', 'water_temp'
 56- NEVER output dosages (acid_dosage_ml, etc.) - the system will compute those from your targets
 57- pH: between 4.0 and 7.5
 58- EC: between 0.1 and 3.0 dS/m
 59- Water Temperature: between 12°C and 28°C
 60- All values must be NUMBERS within these hard constraints
 61- NO markdown, NO code blocks, NO explanations, NO text outside JSON
 62- Invalid JSON will be REJECTED and cause a retry
 63
 64 FORBIDDEN:
 65- Do NOT attempt to control air, light, or CO₂
 66- Do NOT make suggestions unrelated to water chemistry
 67- Do NOT return anything except the JSON object
 68- Do NOT exceed hard constraint bounds under any circumstance
 69
 70 TIP: Call 'diagnose_plant()' (with no arguments) if you suspect nutrient deficiency (e.g., yellowing leaves) or root rot
 71 
 72"""
 73
 74class WaterAgent:
 75    def __init__(self):
 76        self.name = "Water Agent"
 77        
 78        # 1. Initialize Model
 79        if not API_KEY or not ENDPOINT:
 80            print(f"[{self.name}] ⚠️ No Azure OpenAI credentials found.")
 81            self.model = None
 82        else:
 83            llm = AzureChatOpenAI(
 84                azure_endpoint=ENDPOINT,
 85                api_key=API_KEY,
 86                api_version=API_VERSION,
 87                deployment_name=DEPLOYMENT_NAME,
 88                temperature=0.2,
 89                model_kwargs={"tool_choice": "auto", "parallel_tool_calls": False}
 90            )
 91            
 92            # 2. BIND TOOLS
 93            self.model_with_tools = llm.bind_tools([
 94             #   ask_historian, 
 95                web_search,
 96                check_ph_safety,
 97                diagnose_plant, # 🟢 Tool is already here
 98             #   ask_memory
 99            ])
100        
101        # 3. Build the Graph
102        self.app = self._build_graph()
103
104    def _build_graph(self):
105        workflow = StateGraph(AgentState)
106
107        # Nodes
108        workflow.add_node("decide", lambda state: decide_node(state, self.model_with_tools, WATER_PROMPT))
109        workflow.add_node("tools", execute_tools_node)
110        workflow.add_node("simulate", simulate_node)
111        workflow.add_node("finalize", finalize_node)
112        
113        workflow.add_node("skip_unsafe", lambda state: {"final_action": {"ph": 6.0, "ec": 1.5, "water_temp": 22.0}})
114
115        # Flow
116        workflow.set_entry_point("decide")
117
118        def check_decision_output(state):
119            if state.get("next_step") == "tools":
120                return "tools"
121            return "simulate"
122
123        workflow.add_conditional_edges(
124            "decide", 
125            check_decision_output, 
126            {"tools": "tools", "simulate": "simulate"}
127        )
128        
129        workflow.add_edge("tools", "decide")
130
131        def check_simulation_result(state):
132            if state["simulation_result"]["passed"]:
133                return "finalize"
134            elif state["retry_count"] > 3:
135                print(f"[{self.name}] ⚠️ Max retries reached. Skipping execution (no-op).")
136                return "skip_unsafe"
137            else:
138                return "decide"
139
140        workflow.add_conditional_edges(
141            "simulate", 
142            check_simulation_result, 
143            {"finalize": "finalize", "decide": "decide", "skip_unsafe": "skip_unsafe"}
144        )
145        
146        workflow.add_edge("finalize", END)
147        workflow.add_edge("skip_unsafe", END)
148        final_plan = workflow.compile()
149        print("final_plan(Water): ", final_plan)
150        return final_plan
151
152    # 🟢 UPDATE 2: Accept image_b64 and pass to state
153    def reason(self, sensors, research, strategy, history="None", image_b64=None):
154        """Entry point called by main_agent.py"""
155        
156        initial_state = {
157            "sensors": sensors,
158            "research_context": research,
159            "strategy": strategy,
160            "history": history,
161            "image_b64": image_b64, # 🟢 Stored in state for injection
162            "retry_count": 0,
163            "critique": None,
164            "messages": [] 
165        }
166        
167        result = self.app.invoke(initial_state)
168      #  print(f"\n[{self.name}] Final Result: {result}")
169        return result.get("final_action", {})