demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
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", {})