"""Fleet Agent - Manages vehicles, capacity, and real-time tracking.""" import uuid from datetime import datetime, timedelta from typing import Dict, List, Any, Optional from dataclasses import dataclass from enum import Enum from core.agent import SpecializedAgent from core.types import AgentTask, MessageType, Vehicle from core.logger import logger class VehicleStatus(str, Enum): AVAILABLE = "available" ASSIGNED = "assigned" IN_TRANSIT = "in_transit" AT_HUB = "at_hub" MAINTENANCE = "maintenance" OFFLINE = "offline" @dataclass class VehicleAssignment: assignment_id: str vehicle_id: str route_id: str order_ids: List[str] assigned_at: datetime estimated_return: datetime current_location: Dict[str, float] status: str = "active" class FleetAgent(SpecializedAgent): """Fleet Agent - Manages vehicle fleet and logistics operations.""" def __init__(self): super().__init__( agent_id="FLEET_AGENT", domain="fleet_management", description="Manages vehicle fleet, capacity, and real-time tracking" ) self._vehicles = self._init_fleet() self._assignments: Dict[str, VehicleAssignment] = {} self._maintenance_schedule: Dict[str, datetime] = {} self._hub_capacity: Dict[str, Dict] = {} # BUG FIX: hub_capacity init was dead code inside _init_fleet (after return). # Moved here so it actually runs after _vehicles is populated. for vehicle in self._vehicles.values(): hub = vehicle["hub"] if hub not in self._hub_capacity: self._hub_capacity[hub] = {"available": 0, "total": 0} self._hub_capacity[hub]["total"] += 1 if vehicle["status"] == "available": self._hub_capacity[hub]["available"] += 1 def _init_fleet(self) -> Dict[str, Dict]: return { # Delhi "DL-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "DL-HUB-01", "location": {"lat": 28.6139, "lng": 77.2090}}, "DL-V-002": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "DL-HUB-01", "location": {"lat": 28.6139, "lng": 77.2090}}, "DL-V-003": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "DL-HUB-02", "location": {"lat": 28.5355, "lng": 77.2100}}, "DL-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "DL-HUB-01", "location": {"lat": 28.6139, "lng": 77.2090}}, "DL-B-002": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "DL-HUB-02", "location": {"lat": 28.5355, "lng": 77.2100}}, # Mumbai "MU-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "MU-HUB-01", "location": {"lat": 19.0760, "lng": 72.8777}}, "MU-V-002": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "MU-HUB-01", "location": {"lat": 19.0760, "lng": 72.8777}}, "MU-V-003": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "MU-HUB-02", "location": {"lat": 19.1650, "lng": 72.8500}}, "MU-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "MU-HUB-02", "location": {"lat": 19.1650, "lng": 72.8500}}, "MU-T-001": {"type": "truck", "capacity_kg": 2000, "capacity_vol": 25, "status": "available", "hub": "MU-HUB-01", "location": {"lat": 19.0760, "lng": 72.8777}}, # Bangalore "BL-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "BL-HUB-01", "location": {"lat": 12.9716, "lng": 77.5946}}, "BL-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "BL-HUB-01", "location": {"lat": 12.9716, "lng": 77.5946}}, "BL-B-002": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "BL-HUB-01", "location": {"lat": 12.9716, "lng": 77.5946}}, # Hyderabad "HY-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "HY-HUB-01", "location": {"lat": 17.3850, "lng": 78.4867}}, "HY-T-001": {"type": "truck", "capacity_kg": 2000, "capacity_vol": 25, "status": "available", "hub": "HY-HUB-01", "location": {"lat": 17.3850, "lng": 78.4867}}, # Pune "PU-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "PU-HUB-01", "location": {"lat": 18.5204, "lng": 73.8567}}, "PU-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "PU-HUB-01", "location": {"lat": 18.5204, "lng": 73.8567}}, # Kolkata "KL-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "KL-HUB-01", "location": {"lat": 22.5726, "lng": 88.3639}}, "KL-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "KL-HUB-01", "location": {"lat": 22.5726, "lng": 88.3639}}, } async def handle_task(self, task: AgentTask) -> Dict[str, Any]: handlers = { "assign_vehicle": self._assign_vehicle, "release_vehicle": self._release_vehicle, # Sent by ExceptionAgent._cancel_order, which knows the order, not # the vehicle. Had no handler, so every cancellation hit # _unknown_task and the vehicle stayed marked in use. "release_vehicle_for_cancel": self._release_vehicle_for_order, "get_availability": self._get_availability, "track_vehicle": self._track_vehicle, "update_location": self._update_location, "schedule_maintenance": self._schedule_maintenance, "get_fleet_status": self._get_fleet_status, "optimize_allocation": self._optimize_allocation, } handler = handlers.get(task.task_type, self._unknown_task) return await handler(task) async def _assign_vehicle(self, task: AgentTask) -> Dict[str, Any]: route_id = task.data.get("route_id") route_type = task.data.get("route_type", "last_mile") from_hub = task.data.get("from_hub") order_weight = task.data.get("order_weight", 0) logger.info(f"Fleet Agent: Assigning vehicle for route {route_id}") suitable_vehicles = self._find_suitable_vehicles(from_hub, order_weight, route_type) if not suitable_vehicles: suitable_vehicles = self._find_nearest_available_vehicles(from_hub, order_weight) if not suitable_vehicles: logger.warning(f"No suitable vehicles available for route {route_id}") return {"status": "no_vehicle_available", "route_id": route_id, "message": "No suitable vehicles available"} vehicle_id = suitable_vehicles[0] vehicle = self._vehicles[vehicle_id] was_available = vehicle["status"] == "available" vehicle["status"] = "assigned" # Only move the counter when the availability boundary is actually # crossed, so it can't drift out of [0, total]. if was_available and vehicle["hub"] in self._hub_capacity: self._hub_capacity[vehicle["hub"]]["available"] -= 1 assignment_id = f"ASN-{uuid.uuid4().hex[:8].upper()}" assignment = VehicleAssignment( assignment_id=assignment_id, vehicle_id=vehicle_id, route_id=route_id, order_ids=[task.data.get("order_id", "")], assigned_at=datetime.now(), estimated_return=datetime.now() + timedelta(hours=2), current_location=vehicle["location"], status="active" ) self._assignments[assignment_id] = assignment logger.info(f"Vehicle {vehicle_id} ({vehicle['type']}) assigned to route {route_id}") await self.send_message( recipient="DISPATCH_AGENT", message_type=MessageType.VEHICLE_ASSIGNED, payload={"route_id": route_id, "vehicle_id": vehicle_id, "assignment_id": assignment_id}, ) return { "status": "assigned", "assignment_id": assignment_id, "vehicle_id": vehicle_id, "vehicle_type": vehicle["type"], "capacity_kg": vehicle["capacity_kg"], "capacity_vol": vehicle["capacity_vol"], "current_location": vehicle["location"], } async def _release_vehicle(self, task: AgentTask) -> Dict[str, Any]: vehicle_id = task.data.get("vehicle_id") if vehicle_id not in self._vehicles: return {"status": "error", "message": f"Vehicle {vehicle_id} not found"} vehicle = self._vehicles[vehicle_id] was_available = vehicle["status"] == "available" vehicle["status"] = "available" # Only increment when the vehicle was actually unavailable, so a # double-release (or releasing an available vehicle) can't exceed total. if not was_available and vehicle["hub"] in self._hub_capacity: self._hub_capacity[vehicle["hub"]]["available"] += 1 # Close any active assignment for this vehicle so it isn't reported as # still on the road (and stale 'active' rows don't accumulate). for assignment in self._assignments.values(): if assignment.vehicle_id == vehicle_id and assignment.status == "active": assignment.status = "returned" logger.info(f"Vehicle {vehicle_id} released") return {"status": "released", "vehicle_id": vehicle_id, "hub": vehicle["hub"]} async def _release_vehicle_for_order(self, task: AgentTask) -> Dict[str, Any]: """Release the vehicle carrying a cancelled order, found by order id.""" order_id = task.data.get("order_id") for assignment in self._assignments.values(): if assignment.status == "active" and order_id in (assignment.order_ids or []): return await self._release_vehicle(AgentTask( task_id=f"{task.task_id}:release", agent_type=task.agent_type, task_type="release_vehicle", data={"vehicle_id": assignment.vehicle_id}, )) return {"status": "no_assignment", "order_id": order_id, "message": f"No active vehicle assignment carries order {order_id}"} async def _get_availability(self, task: AgentTask) -> Dict[str, Any]: hub_id = task.data.get("hub_id") vehicle_type = task.data.get("vehicle_type") available = [] for vid, vehicle in self._vehicles.items(): if vehicle["hub"] == hub_id and vehicle["status"] == "available": if vehicle_type is None or vehicle["type"] == vehicle_type: available.append({ "vehicle_id": vid, "type": vehicle["type"], "capacity_kg": vehicle["capacity_kg"], "capacity_vol": vehicle["capacity_vol"], }) return {"hub_id": hub_id, "available_vehicles": available, "total_available": len(available)} async def _track_vehicle(self, task: AgentTask) -> Dict[str, Any]: vehicle_id = task.data.get("vehicle_id") if vehicle_id not in self._vehicles: return {"status": "error", "message": f"Vehicle {vehicle_id} not found"} vehicle = self._vehicles[vehicle_id] active_assignment = next( (a for a in self._assignments.values() if a.vehicle_id == vehicle_id and a.status == "active"), None, ) return { "vehicle_id": vehicle_id, "type": vehicle["type"], "status": vehicle["status"], "location": vehicle["location"], "hub": vehicle["hub"], "current_assignment": { "assignment_id": active_assignment.assignment_id, "route_id": active_assignment.route_id, "estimated_return": active_assignment.estimated_return.isoformat(), } if active_assignment else None, } async def _update_location(self, task: AgentTask) -> Dict[str, Any]: vehicle_id = task.data.get("vehicle_id") new_location = task.data.get("location") if vehicle_id not in self._vehicles: return {"status": "error", "message": f"Vehicle {vehicle_id} not found"} self._vehicles[vehicle_id]["location"] = new_location for assignment in self._assignments.values(): if assignment.vehicle_id == vehicle_id and assignment.status == "active": assignment.current_location = new_location break return {"status": "updated", "vehicle_id": vehicle_id, "new_location": new_location} async def _schedule_maintenance(self, task: AgentTask) -> Dict[str, Any]: vehicle_id = task.data.get("vehicle_id") maintenance_date = task.data.get("date") if vehicle_id not in self._vehicles: return {"status": "error", "message": f"Vehicle {vehicle_id} not found"} if maintenance_date: self._maintenance_schedule[vehicle_id] = datetime.fromisoformat(maintenance_date) else: self._maintenance_schedule[vehicle_id] = datetime.now() + timedelta(days=3) # Taking a vehicle out of service reduces availability (only if it was # counted as available), keeping hub capacity accurate. vehicle = self._vehicles[vehicle_id] if vehicle["status"] == "available" and vehicle["hub"] in self._hub_capacity: self._hub_capacity[vehicle["hub"]]["available"] -= 1 vehicle["status"] = "maintenance" return { "status": "scheduled", "vehicle_id": vehicle_id, "maintenance_date": self._maintenance_schedule[vehicle_id].isoformat(), } async def _get_fleet_status(self, task: AgentTask) -> Dict[str, Any]: status_summary = { "total_vehicles": len(self._vehicles), "by_type": {}, "by_status": {}, "by_hub": {}, "vehicles": [], } for vehicle_id, vehicle in self._vehicles.items(): vtype = vehicle["type"] vstatus = vehicle["status"] hub = vehicle["hub"] status_summary["by_type"][vtype] = status_summary["by_type"].get(vtype, 0) + 1 status_summary["by_status"][vstatus] = status_summary["by_status"].get(vstatus, 0) + 1 if hub not in status_summary["by_hub"]: status_summary["by_hub"][hub] = {"total": 0, "available": 0} status_summary["by_hub"][hub]["total"] += 1 if vstatus == "available": status_summary["by_hub"][hub]["available"] += 1 status_summary["vehicles"].append({ "vehicle_id": vehicle_id, "type": vtype, "status": vstatus, "hub": hub, "location": vehicle["location"], }) return status_summary async def _optimize_allocation(self, task: AgentTask) -> Dict[str, Any]: logger.info("Fleet Agent: Optimizing vehicle allocation") recommendations = [] for hub_id, capacity in self._hub_capacity.items(): utilization = (capacity["total"] - capacity["available"]) / capacity["total"] if capacity["total"] > 0 else 0 if utilization < 0.3: recommendations.append({ "action": "redistribute", "from_hub": hub_id, "reason": f"Low utilization ({utilization*100:.0f}%)", "vehicles_to_move": 1, }) elif utilization > 0.9: recommendations.append({ "action": "request_reinforcement", "to_hub": hub_id, "reason": f"High utilization ({utilization*100:.0f}%)", "vehicles_needed": 2, }) return {"status": "optimized", "recommendations": recommendations} async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]: return {"status": "error", "message": f"Unknown task: {task.task_type}"} def _find_suitable_vehicles(self, hub_id: str, weight_kg: float, route_type: str) -> List[str]: suitable = [] for vehicle_id, vehicle in self._vehicles.items(): if vehicle["hub"] != hub_id: continue if vehicle["status"] != "available": continue if vehicle["capacity_kg"] < weight_kg: continue if route_type == "last_mile" and vehicle["type"] in ["van", "truck"]: continue suitable.append(vehicle_id) return suitable def _find_nearest_available_vehicles(self, hub_id: str, weight_kg: float) -> List[str]: available = [ vid for vid, v in self._vehicles.items() if v["status"] == "available" and v["capacity_kg"] >= weight_kg ] return available[:1] async def think(self, context: str, options: List[str] = None) -> str: return f"[FLEET_AGENT reasoning]: {context}"