demeter

Autonomous Hydroponic Intelligence
commit 756e9917c24a850a845d0d097742187736f0bc28
parent a301b5fbe31e3215dee2e5f4dab6a2b544d880b7
Author: AbhinavRai01 <abhinavrai004@gmail.com>
Date:   Sun,  1 Mar 2026 18:55:04 +0000

trying to connect shit up

Diffstat:
Magent/main_agent.py | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Magent/sub_agents/Supervisor.py | 131++++++++++++++++++++++++++-----------------------------------------------------
Magent/sub_agents/atmospheric_agent.py | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aagent/sub_agents/base_agent.py | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Magent/sub_agents/fetching_agent.py | 205+++++++++++++++++++++++++++++++++----------------------------------------------
Magent/sub_agents/water_agent.py | 44++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 367 insertions(+), 208 deletions(-)

diff --git a/agent/main_agent.py b/agent/main_agent.py @@ -0,0 +1,95 @@ +import sys +import os +import requests +import time +from pathlib import Path + +# --- PATH SETUP --- +current_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(current_dir) + +# Import Agents +from sub_agents.fetching_agent import FetchingAgent +from sub_agents.atmospheric_agent import AtmosphericAgent +from sub_agents.water_agent import WaterAgent +from sub_agents.Researcher import ResearcherAgent # Ensure this file exists +from sub_agents.Supervisor import SupervisorAgent + +# Simulator Action URL +SIMULATOR_ACTION_URL = "https://unexhumed-melaine-bouncingly.ngrok-free.dev/simulation/action" + +def main(): + print("šŸš€ Initializing Demeter Orchestrator...") + + # 1. Instantiate All Agents + fetcher = FetchingAgent() + researcher = ResearcherAgent() + atmos_agent = AtmosphericAgent() + water_agent = WaterAgent() + supervisor = SupervisorAgent() + + while True: + print("\n" + "="*50) + print("ā±ļø STARTING NEW CYCLE") + print("="*50) + + # 2. Fetch Reality (Current State + History) + fmu, sensor_snapshot, history = fetcher.fetch_and_process() + + if not fmu: + print("āŒ Fetch failed or Simulator offline. Retrying in 10s...") + time.sleep(10) + continue + + # Extract Context + crop = fmu.metadata.get("crop", "unknown") + stage = fmu.metadata.get("stage", "unknown") + + print(f"\n[Context] Crop: {crop} | Stage: {stage}") + print(f"[Context] Current Sensors: {sensor_snapshot}") + + # 3. Get Research Knowledge + research_context = researcher.consult_knowledge_base(crop, stage) + + # 4. Domain Agent Reasoning + # They analyze the SENSORS against the RESEARCH + print("\n🧠 Domain Agents Deliberating...") + + # Atmospheric Agent (Controls CO2, Light, Air Temp, Humidity) + atmos_plan = atmos_agent.reason(sensor_snapshot, research_context) + print(f" šŸ‘‰ Atmospheric Plan: {atmos_plan}") + + # Water Agent (Controls pH, EC, Water Temp) + water_plan = water_agent.reason(sensor_snapshot, research_context) + print(f" šŸ‘‰ Water Plan: {water_plan}") + + # 5. Supervisor Synthesis & Validation + # Merges plans and checks against HISTORY for safety + print("\nšŸ‘® Supervisor Validating...") + final_action = supervisor.synthesize_plan( + atmos_plan, + water_plan, + fmu.metadata, + history + ) + + print(f"šŸŽÆ FINAL COMMAND: {final_action}") + + # 6. Execute (Send to Simulator) + try: + print(f"šŸ“” Sending command to Simulator...") + resp = requests.post(SIMULATOR_ACTION_URL, json=final_action) + + if resp.status_code == 200: + print("āœ… Action accepted by Simulator.") + else: + print(f"āš ļø Simulator rejected action: {resp.status_code} - {resp.text}") + except Exception as e: + print(f"āŒ Connection error: {e}") + + # Wait for next cycle + print("\nzzz Sleeping 15s...") + time.sleep(15) + +if __name__ == "__main__": + main() +\ No newline at end of file diff --git a/agent/sub_agents/Supervisor.py b/agent/sub_agents/Supervisor.py @@ -1,98 +1,51 @@ -import json import os -from dotenv import load_dotenv from openai import OpenAI -# Load .env file relative to this script -current_dir = os.path.dirname(os.path.abspath(__file__)) -env_path = os.path.join(current_dir, '../../.env') -load_dotenv(env_path) +MODEL_ID = "llama3-70b-8192" +API_KEY = os.environ.get("GROQ_API_KEY") class SupervisorAgent: - def __init__(self, researcher_agent): - self.researcher = researcher_agent - - # ⚔ CONNECT TO GROQ CLOUD - # CHECK: Ensure your .env file has 'GROK_API_KEY' or 'GROQ_API_KEY' - # We use 'GROQ_API_KEY' here based on your previous messages - api_key = os.getenv("GROQ_API_KEY") - - if not api_key: - print("āš ļø WARNING: API Key not found. Supervisor may fail.") - - self.llm = OpenAI( - base_url="https://api.groq.com/openai/v1", - api_key=api_key - ) - - def reason(self, current_fmu, similar_fmus, sub_agent_outputs): + def __init__(self): + self.name = "Supervisor" + if not API_KEY: + self.client = None + else: + self.client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=API_KEY) + + def synthesize_plan(self, atmos_plan: dict, water_plan: dict, current_meta: dict, history: list) -> dict: """ - The Core Reasoning Loop: - 1. Contextualize -> 2. Research -> 3. Synthesize -> 4. Decide - """ - - # --- STEP 1: Formulate the Research Question --- - crop = current_fmu['metadata'].get('crop', 'Unknown Crop') - stage = current_fmu['metadata'].get('stage', 'Unknown Stage') - - # E.g., "Lettuce Vegetative Low pH issues" - research_query = f"{crop} {stage} {sub_agent_outputs.get('nutrient_analysis', '')} issues" - - print(f"šŸ¤” Supervisor is asking Researcher: '{research_query}'") - - # --- STEP 2: The Researcher Fetches Evidence (RAG) --- - # This now returns a clean STRING, not a list - scientific_context = self.researcher.search(research_query) - - # --- STEP 3: Synthesize History (Memory) --- - history_context = "\n".join([ - f"- Previous Case (Score {f['score']:.2f}): {f['payload'].get('outcome', 'No outcome recorded')}" - for f in similar_fmus - ]) - - # --- STEP 4: The Final Prompt --- - system_prompt = """ - You are the Chief Supervisor AI of a Hydroponic Facility. - Your goal: Synthesize conflicting data to recommend the OPTIMAL action. - - PRINCIPLES: - 1. Plant Health is Priority #1. - 2. Verify Sub-Agent claims against the SCIENTIFIC KNOWLEDGE provided. - 3. If History contradicts Science, prefer Science (Manuals), but note the anomaly. - """ - - user_message = f""" - ### SITUATION REPORT - Target: {crop} ({stage}) - Sensors: {current_fmu['payload']['sensors']} - - ### SUB-AGENT ALERTS - {json.dumps(sub_agent_outputs, indent=2)} - - ### SCIENTIFIC KNOWLEDGE (Verified Manuals) - {scientific_context} - - ### HISTORICAL MEMORY (Similar Past Events) - {history_context} - - ### COMMAND - Analyze the situation. Resolve conflicts between agents using the Manuals. - Output JSON: {{ "reasoning": "...", "action": "...", "confidence": 0.0-1.0 }} + Takes plans from sub-agents and creates the final JSON payload for the Simulator. """ + print(f"[{self.name}] šŸ‘® Validating and merging plans...") + + # 1. Merge the plans + # We start with the sub-agent recommendations + combined_action = {**atmos_plan, **water_plan} + + # 2. Reasoning (Optional: Check for conflicts) + prompt = ( + f"You are the Farm Supervisor.\n" + f"Proposed Atmospheric Actions: {atmos_plan}\n" + f"Proposed Water Actions: {water_plan}\n" + f"Current Crop: {current_meta.get('crop')} ({current_meta.get('stage')})\n" + f"Similar History: {history}\n\n" + f"TASK: Review these actions. If they are safe, merge them into a single JSON object.\n" + f"If there is a conflict (e.g., high Temp but low CO2), adjust them.\n" + f"OUTPUT: A clean JSON object strictly matching the simulator's expected action keys.\n" + f"Do not add markdown." + ) - # --- STEP 5: Execute Reasoning on Groq --- try: - response = self.llm.chat.completions.create( - # We use Llama-3.1-8b because it is fast and smart enough for this logic - model="llama-3.1-8b-instant", - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_message} - ], - temperature=0.1, # Low temp for strict logic - response_format={"type": "json_object"} # Force valid JSON + # For speed, we might just return the combined dict, + # but here we ask the LLM to validate/format it. + response = self.client.chat.completions.create( + model=MODEL_ID, + messages=[{"role": "user", "content": prompt}] ) - return json.loads(response.choices[0].message.content) - - except Exception as e: - return {"error": str(e), "reasoning": "Groq Connection Failed"} -\ No newline at end of file + content = response.choices[0].message.content.replace("```json", "").replace("```", "").strip() + import ast + final_json = ast.literal_eval(content) + return final_json + except Exception: + # Fallback: Just return the merged dicts + return combined_action +\ No newline at end of file diff --git a/agent/sub_agents/atmospheric_agent.py b/agent/sub_agents/atmospheric_agent.py @@ -0,0 +1,49 @@ +import os +from openai import OpenAI + +# Configuration +MODEL_ID = "llama3-70b-8192" +API_KEY = os.environ.get("GROQ_API_KEY") + +class AtmosphericAgent: + def __init__(self): + self.name = "Atmospheric Agent" + if not API_KEY: + print(f"[{self.name}] āš ļø GROQ_API_KEY missing.") + self.client = None + else: + self.client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=API_KEY) + + def reason(self, current_state: dict, research_context: str) -> dict: + """ + Decides on CO2, Light, Temp, Humidity changes. + Returns a dict of actions (e.g., {"CO2": 1200, "Light": 800}) + """ + print(f"[{self.name}] šŸŒ¤ļø Analyzing Air conditions...") + + prompt = ( + f"You are the Atmospheric Control System.\n" + f"RESEARCH GUIDELINES:\n{research_context}\n\n" + f"CURRENT STATE:\n{current_state}\n\n" + f"TASK: Output a JSON dictionary ONLY of target values for 'co2' (ppm), 'light_intensity' (umol), " + f"'air_temp' (C), and 'humidity' (%).\n" + f"Example format: {{'co2': 1000, 'light_intensity': 600, 'air_temp': 24, 'humidity': 60}}\n" + f"Do not add markdown formatting or explanation." + ) + + if not self.client: + return {"co2": 400, "light_intensity": 500} # Defaults + + try: + response = self.client.chat.completions.create( + model=MODEL_ID, + messages=[{"role": "user", "content": prompt}] + ) + # Simple cleaning to ensure valid JSON + content = response.choices[0].message.content.replace("```json", "").replace("```", "").strip() + # In a real system, use json.loads(content) with error handling + import ast + return ast.literal_eval(content) + except Exception as e: + print(f"[{self.name}] Error: {e}") + return {} +\ No newline at end of file diff --git a/agent/sub_agents/base_agent.py b/agent/sub_agents/base_agent.py @@ -0,0 +1,48 @@ +import os +from openai import OpenAI + +# --- GROQ CONFIGURATION --- +# Common Groq Models: "llama3-70b-8192", "mixtral-8x7b-32768" +MODEL_ID = "llama3-70b-8192" +API_KEY = os.environ.get("GROQ_API_KEY") + +class BaseReasoningAgent: + def __init__(self, name): + self.name = name + + # ⚔ Connect to Groq via OpenAI Client + if not API_KEY: + print(f"[{self.name}] āš ļø WARNING: GROQ_API_KEY not found in environment.") + self.client = None + else: + try: + self.client = OpenAI( + base_url="https://api.groq.com/openai/v1", + api_key=API_KEY + ) + except Exception as e: + print(f"[{self.name}] āš ļø Groq Connection Error: {e}") + self.client = None + + def _call_llm(self, prompt): + """ + Helper method to send prompts to Groq Cloud. + """ + if not self.client: + return "Error: LLM Client not connected (Check API Key)." + + try: + # Groq/OpenAI Chat Completion Structure + response = self.client.chat.completions.create( + model=MODEL_ID, + messages=[ + {"role": "system", "content": f"You are the {self.name} Agent for a high-tech hydroponic farm."}, + {"role": "user", "content": prompt} + ], + temperature=0.6, # Slightly lower temp for more stable control decisions + max_tokens=1024 + ) + return response.choices[0].message.content + + except Exception as e: + return f"Reasoning Error: {e}" +\ No newline at end of file diff --git a/agent/sub_agents/fetching_agent.py b/agent/sub_agents/fetching_agent.py @@ -5,9 +5,8 @@ import json from pathlib import Path # --- PATH FIX --- -# Ensures Python can find 'Sentinel' and 'Qdrant' folders current_file = Path(__file__).resolve() -project_root = current_file.parent.parent +project_root = current_file.parent.parent.parent # Adjusted for sub_agents nesting sys.path.append(str(project_root)) # ---------------- @@ -16,25 +15,83 @@ from Sentinel.agent import FMUBuilder from Qdrant.Store import store_fmu, COLLECTION_NAME from Qdrant.Client import client -# ========================================== -# šŸ•µļø HISTORIAN AGENT -# ========================================== -class HistorianAgent: - def __init__(self): - self.client = client # Use the shared Qdrant client - self.collection = COLLECTION_NAME +class FetchingAgent: + def __init__(self, simulator_url="https://unexhumed-melaine-bouncingly.ngrok-free.dev/simulation/state"): + self.sim_url = simulator_url + self.builder = FMUBuilder() - def consult_history(self, fmu): - """ - Takes an FMU, extracts its metadata/vector, and searches - for similar past instances with the same Crop & Stage. - """ - target_crop = fmu.metadata.get("crop") - target_stage = fmu.metadata.get("stage") + def fetch_and_process(self): + print(f"[Fetcher] šŸ“” Requesting data from {self.sim_url}...") + + try: + response = requests.get(self.sim_url) + + if response.status_code == 200: + data = response.json() + + # --- 1. EXTRACT RAW DATA --- + window_data = data.get("sensor_window", {}) + image_b64 = data.get("image", "") + raw_meta = data.get("metadata", {}) + + # --- 2. FILTER SENSORS (Strictly the 4 requested) --- + wanted_keys = { + "ph": "pH", + "ec": "EC", + "humidity": "humidity", + "temp": "temp", + "air_temp": "temp" + } + + sensor_snapshot = {} + for key, value_list in window_data.items(): + key_lower = key.lower() + if key_lower in wanted_keys: + out_name = wanted_keys[key_lower] + val = value_list[-1] if isinstance(value_list, list) and value_list else 0.0 + if out_name not in sensor_snapshot: + sensor_snapshot[out_name] = val + + # --- 3. FILTER METADATA (Crop & Stage) --- + filtered_metadata = { + "crop": raw_meta.get("crop", "unknown"), + "stage": raw_meta.get("stage", "unknown") + } + + # --- 4. CREATE FMU --- + fmu = self.builder.create_fmu(image_b64, sensor_snapshot, filtered_metadata) + + print(f"\n[Fetcher] 🧠 FMU Created (ID: {fmu.id})") + print(f"[Fetcher] Sensors: {sensor_snapshot}") + + # --- 5. STORE IN QDRANT --- + store_fmu(fmu) + + # --- 6. HISTORIAN SEARCH (Integrated) --- + search_results = self.find_similar_instances(fmu) + + print(f"[Historian] šŸ“œ Found {len(search_results)} similar past events.") + + # RETURN EVERYTHING THE ORCHESTRATOR NEEDS + return fmu, sensor_snapshot, search_results + else: + print(f"[Fetcher] āŒ Error: Simulator returned {response.status_code}") + return None, None, None - print(f"\n[Historian] šŸ“œ Consulting archives for {target_crop} ({target_stage})...") + except Exception as e: + print(f"[Fetcher] āŒ Critical Error: {e}") + import traceback + traceback.print_exc() + return None, None, None - # 1. Create Context Filter + def find_similar_instances(self, current_fmu): + """ + Uses the current FMU's vector and metadata to filter and search Qdrant. + """ + target_crop = current_fmu.metadata.get("crop") + target_stage = current_fmu.metadata.get("stage") + + # Create Filter context_filter = models.Filter( must=[ models.FieldCondition(key="crop", match=models.MatchValue(value=target_crop)), @@ -42,111 +99,21 @@ class HistorianAgent: ] ) - # 2. Search Qdrant try: - # We use the vector from the FMU directly - results = self.client.search( - collection_name=self.collection, - query_vector=fmu.vector, + # Use the vector we just generated + response = client.search( + collection_name=COLLECTION_NAME, + query_vector=current_fmu.vector, query_filter=context_filter, - limit=3, - with_payload=True - ) - except Exception as e: - print(f"[Historian] āš ļø Search Error: {e}. Trying unfiltered search...") - # Fallback if filters fail (e.g. missing payload index) - results = self.client.search( - collection_name=self.collection, - query_vector=fmu.vector, - limit=3, + limit=5, with_payload=True ) - - # 3. Report Results - if not results: - print("[Historian] 🤷 No similar history found.") - return [] - - print(f"[Historian] āœ… Found {len(results)} matches:") - formatted_history = [] - for hit in results: - print(f" - ID: {hit.id} | Score: {hit.score:.4f}") - formatted_history.append(hit.payload) - - return formatted_history - - -# ========================================== -# šŸ“” FETCHING AGENT -# ========================================== -class FetchingAgent: - def __init__(self, simulator_url="https://unexhumed-melaine-bouncingly.ngrok-free.dev/simulation/state"): - self.sim_url = simulator_url - self.builder = FMUBuilder() - - def run_cycle(self): - print(f"\n[Fetcher] šŸ“” Requesting data from {self.sim_url}...") - - try: - response = requests.get(self.sim_url) - - if response.status_code != 200: - print(f"[Fetcher] āŒ Error: Simulator returned {response.status_code}") - return None - - data = response.json() - - # --- 1. Filter Sensors (pH, EC, Temp, Humidity) --- - window_data = data.get("sensor_window", {}) - wanted_keys = {"ph": "pH", "ec": "EC", "humidity": "humidity", "temp": "temp", "air_temp": "temp"} - - sensor_snapshot = {} - for key, value_list in window_data.items(): - key_lower = key.lower() - if key_lower in wanted_keys: - out_name = wanted_keys[key_lower] - val = value_list[-1] if isinstance(value_list, list) and value_list else 0.0 - if out_name not in sensor_snapshot: - sensor_snapshot[out_name] = val - - # --- 2. Filter Metadata (Crop, Stage) --- - raw_meta = data.get("metadata", {}) - filtered_metadata = { - "crop": raw_meta.get("crop", "unknown"), - "stage": raw_meta.get("stage", "unknown") - } - - # --- 3. Create FMU --- - image_b64 = data.get("image", "") - fmu = self.builder.create_fmu(image_b64, sensor_snapshot, filtered_metadata) - - print(f"[Fetcher] 🧠 FMU Created (ID: {fmu.id})") - print(f"[Fetcher] Sensors: {sensor_snapshot}") - - # --- 4. Store in DB --- - store_fmu(fmu) + return [{"id": hit.id, "score": hit.score, "payload": hit.payload} for hit in response] - return fmu - except Exception as e: - print(f"[Fetcher] āŒ Critical Error: {e}") - return None - + print(f"āš ļø Search Warning: {e}. Returning empty history.") + return [] -# ========================================== -# šŸŽ¬ LOCAL ORCHESTRATION -# ========================================== if __name__ == "__main__": - # 1. Instantiate Agents - fetcher = FetchingAgent() - historian = HistorianAgent() - - # 2. Run the Loop - print("--- Starting Combined Agent Cycle ---") - - # Step A: Fetch & Process - current_fmu = fetcher.run_cycle() - - # Step B: Consult History (if Fetch was successful) - if current_fmu: - historian.consult_history(current_fmu) -\ No newline at end of file + agent = FetchingAgent() + agent.fetch_and_process() +\ No newline at end of file diff --git a/agent/sub_agents/water_agent.py b/agent/sub_agents/water_agent.py @@ -0,0 +1,43 @@ +import os +from openai import OpenAI + +MODEL_ID = "llama3-70b-8192" +API_KEY = os.environ.get("GROQ_API_KEY") + +class WaterAgent: + def __init__(self): + self.name = "Water Agent" + if not API_KEY: + self.client = None + else: + self.client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=API_KEY) + + def reason(self, current_state: dict, research_context: str) -> dict: + """ + Decides on pH, EC, Water Temp changes. + """ + print(f"[{self.name}] šŸ’§ Analyzing Water solution...") + + prompt = ( + f"You are the Water Chemistry System.\n" + f"RESEARCH GUIDELINES:\n{research_context}\n\n" + f"CURRENT STATE:\n{current_state}\n\n" + f"TASK: Output a JSON dictionary ONLY of target values for 'ph', 'ec' (dS/m), and 'water_temp' (C).\n" + f"Example format: {{'ph': 5.8, 'ec': 1.5, 'water_temp': 20}}\n" + f"Do not add markdown formatting." + ) + + if not self.client: + return {"ph": 6.0, "ec": 1.2} + + try: + response = self.client.chat.completions.create( + model=MODEL_ID, + messages=[{"role": "user", "content": prompt}] + ) + content = response.choices[0].message.content.replace("```json", "").replace("```", "").strip() + import ast + return ast.literal_eval(content) + except Exception as e: + print(f"[{self.name}] Error: {e}") + return {} +\ No newline at end of file