Initial commit

This commit is contained in:
2026-06-26 16:08:31 +05:30
commit f49193ee73
23 changed files with 6056 additions and 0 deletions

18
agents/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
# Agents module for LogiFlow AI
from agents.order_agent import OrderAgent
from agents.dispatch_agent import DispatchAgent
from agents.fleet_agent import FleetAgent
from agents.hub_agent import HubAgent
from agents.customer_agent import CustomerAgent
from agents.exception_agent import ExceptionAgent
from agents.route_optimizer_agent import RouteOptimizerAgent
__all__ = [
'OrderAgent',
'DispatchAgent',
'FleetAgent',
'HubAgent',
'CustomerAgent',
'ExceptionAgent',
'RouteOptimizerAgent'
]

407
agents/customer_agent.py Normal file
View File

@@ -0,0 +1,407 @@
"""Customer Agent - Handles notifications, tracking, and customer communication."""
import uuid
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
from enum import Enum
import aiohttp
from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType, OrderStatus
from core.logger import logger
from core.http_client import api_post, api_get
from config.system_config import GO_API_BASE_URL, INTERNAL_API_KEY
class NotificationChannel(str, Enum):
SMS = "sms"
EMAIL = "email"
PUSH = "push"
WHATSAPP = "whatsapp"
class NotificationType(str, Enum):
ORDER_CONFIRMED = "order_confirmed"
PICKUP_SCHEDULED = "pickup_scheduled"
PICKED_UP = "picked_up"
IN_TRANSIT = "in_transit"
ARRIVED_AT_HUB = "arrived_at_hub"
OUT_FOR_DELIVERY = "out_for_delivery"
DELIVERED = "delivered"
DELAYED = "delayed"
RESCHEDULED = "rescheduled"
CANCELLED = "cancelled"
@dataclass
class Notification:
notification_id: str
order_id: str
customer_id: str
channel: NotificationChannel
notification_type: NotificationType
message: str
sent_at: datetime
delivered_at: Optional[datetime]
status: str # pending, sent, delivered, failed
class CustomerAgent(SpecializedAgent):
"""
Customer Agent - Manages customer communications and notifications.
Sends via Go backend (POST /api/v1/internal/notify → real FCM/SMS/WhatsApp).
Keeps local notification history for audit / query.
"""
def __init__(self):
super().__init__(
agent_id="CUSTOMER_AGENT",
domain="customer_communication",
description="Handles notifications, tracking updates, and customer communication"
)
self._customers: Dict[str, Dict] = {}
self._notifications: Dict[str, List[Notification]] = {}
self._templates = self._init_templates()
self._preferences: Dict[str, Dict] = {}
def _init_templates(self) -> Dict[str, Dict]:
return {
NotificationType.ORDER_CONFIRMED: {
"sms": "Your order {order_id} has been confirmed! Estimated delivery: {eta}",
"email": "Hi {customer_name}, your order {order_id} is confirmed. Track at: {tracking_url}",
"whatsapp": "Order {order_id} confirmed! Delivery by {eta}. Track: {tracking_url}"
},
NotificationType.PICKUP_SCHEDULED: {
"sms": "Pickup scheduled for {pickup_time}. Driver will arrive within 30 mins.",
"email": "Your pickup is scheduled for {pickup_time}",
"whatsapp": "Pickup scheduled for {pickup_time}. Driver arrives in 30 mins"
},
NotificationType.PICKED_UP: {
"sms": "Order {order_id} picked up and on its way!",
"email": "Your package has been picked up and is in transit.",
"whatsapp": "Order {order_id} picked up! Heading to {destination}"
},
NotificationType.IN_TRANSIT: {
"sms": "Your order {order_id} is now in transit.",
"email": "Update: Your order is on the move!",
"whatsapp": "Order {order_id} in transit. Current stop: {current_location}"
},
NotificationType.ARRIVED_AT_HUB: {
"sms": "Your order {order_id} has arrived at {hub_name}.",
"email": "Your package has arrived at the {hub_name} facility.",
"whatsapp": "Order {order_id} arrived at {hub_name}"
},
NotificationType.OUT_FOR_DELIVERY: {
"sms": "Out for delivery! Driver: {driver_name}, Contact: {driver_phone}",
"email": "Your order is out for delivery!",
"whatsapp": "Out for delivery! Driver: {driver_name} | {driver_phone}"
},
NotificationType.DELIVERED: {
"sms": "Order {order_id} delivered successfully! Thank you for choosing us.",
"email": "Your order has been delivered! We hope you enjoy your purchase.",
"whatsapp": "Delivered! Order {order_id}. Thank you for shopping with us!"
},
NotificationType.DELAYED: {
"sms": "Delay alert: Order {order_id} may arrive later than expected. New ETA: {new_eta}",
"email": "We apologize for the delay. Your order's new ETA is {new_eta}",
"whatsapp": "Delay update: Order {order_id}. New ETA: {new_eta}"
},
NotificationType.RESCHEDULED: {
"sms": "Delivery rescheduled. New date: {new_date}",
"email": "Your delivery has been rescheduled to {new_date}",
"whatsapp": "Rescheduled! New delivery: {new_date}"
},
NotificationType.CANCELLED: {
"sms": "Order {order_id} has been cancelled. Refund processing: {refund_info}",
"email": "Your order has been cancelled. {refund_info}",
"whatsapp": "Cancelled: Order {order_id}. {refund_info}"
}
}
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
handlers = {
"send_confirmation": self._send_confirmation,
"send_notification": self._send_notification,
"update_tracking": self._update_tracking,
"get_tracking": self._get_tracking,
"send_bulk_notification": self._send_bulk_notification,
"get_customer_history": self._get_customer_history,
"handle_query": self._handle_query,
}
handler = handlers.get(task.task_type, self._unknown_task)
return await handler(task)
async def _send_confirmation(self, task: AgentTask) -> Dict[str, Any]:
order_data = task.data.get("order", {})
order_id = order_data.get("order_id")
customer_phone = order_data.get("customer_phone")
customer_email = order_data.get("customer_email")
customer_name = order_data.get("customer_name", "Customer")
eta = task.data.get("eta", "tomorrow")
logger.info(f"Customer Agent: Sending confirmation for order {order_id}")
if customer_phone and customer_phone not in self._customers:
self._customers[customer_phone] = {
"name": customer_name,
"email": customer_email,
"orders": []
}
if customer_phone in self._customers:
self._customers[customer_phone]["orders"].append(order_id)
template_vars = {
"order_id": order_id,
"customer_name": customer_name,
"eta": eta,
"tracking_url": f"https://track.doormile.com/{order_id}"
}
notifications_sent = []
if customer_phone:
notifications_sent.append(await self._send_via_channel(
order_id=order_id, customer_id=customer_phone,
channel=NotificationChannel.SMS,
notification_type=NotificationType.ORDER_CONFIRMED,
template_vars=template_vars,
))
notifications_sent.append(await self._send_via_channel(
order_id=order_id, customer_id=customer_phone,
channel=NotificationChannel.WHATSAPP,
notification_type=NotificationType.ORDER_CONFIRMED,
template_vars=template_vars,
))
if customer_email:
notifications_sent.append(await self._send_via_channel(
order_id=order_id, customer_id=customer_email,
channel=NotificationChannel.EMAIL,
notification_type=NotificationType.ORDER_CONFIRMED,
template_vars=template_vars,
))
return {"status": "sent", "order_id": order_id, "notifications": notifications_sent}
async def _send_notification(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
notification_type = task.data.get("notification_type")
template_vars = task.data.get("template_vars", {})
customer_id = task.data.get("customer_id", "unknown")
logger.info(f"Customer Agent: Sending {notification_type} for order {order_id}")
notification = await self._send_via_channel(
order_id=order_id,
customer_id=customer_id,
channel=NotificationChannel.SMS,
notification_type=NotificationType(notification_type),
template_vars=template_vars,
)
return {"status": "sent", "notification": notification}
async def _update_tracking(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
status = task.data.get("status")
location = task.data.get("location")
eta = task.data.get("eta")
logger.info(f"Customer Agent: Updating tracking for order {order_id} | status={status} location={location}")
status_notification_map = {
"picked_up": NotificationType.PICKED_UP,
"in_transit": NotificationType.IN_TRANSIT,
"at_hub": NotificationType.ARRIVED_AT_HUB,
"out_for_delivery": NotificationType.OUT_FOR_DELIVERY,
"delivered": NotificationType.DELIVERED,
"delayed": NotificationType.DELAYED,
}
notification_type = status_notification_map.get(status)
if notification_type:
await self._send_via_channel(
order_id=order_id,
customer_id=task.data.get("customer_id", ""),
channel=NotificationChannel.SMS,
notification_type=notification_type,
template_vars={
"order_id": order_id,
"current_location": location,
"new_eta": eta,
"hub_name": location,
},
)
return {"status": "updated", "order_id": order_id, "new_status": status, "location": location}
async def _get_tracking(self, task: AgentTask) -> Dict[str, Any]:
booking_id = task.data.get("booking_id") or task.data.get("order_id")
result = await api_get(
f"{GO_API_BASE_URL}/api/v1/bookings/cache/{booking_id}",
headers={"X-Internal-Key": INTERNAL_API_KEY},
)
if not result:
logger.warning(f"Tracking lookup failed for booking {booking_id}")
return {"status": "not_found", "order_id": booking_id}
data = result.get("data") or result
return {
"order_id": booking_id,
"current_status": data.get("status", "unknown"),
"miler_id": data.get("assignedmileruserid"),
"miler_name": data.get("milername"),
"miler_phone": data.get("milerphone"),
"estimated_delivery": data.get("estimateddelivery"),
"pickup_address": data.get("pickupaddress"),
"delivery_address": data.get("deliveryaddress"),
"last_update": data.get("updatedat") or datetime.now().isoformat(),
}
async def _send_bulk_notification(self, task: AgentTask) -> Dict[str, Any]:
order_ids = task.data.get("order_ids", [])
notification_type = task.data.get("notification_type")
template_vars = task.data.get("template_vars", {})
logger.info(f"Customer Agent: Sending bulk notification to {len(order_ids)} customers")
results = []
for order_id in order_ids:
result = await self._send_notification(AgentTask(
task_id=f"bulk_{order_id}",
agent_type="customer",
task_type="send_notification",
data={
"order_id": order_id,
"notification_type": notification_type,
"template_vars": template_vars,
}
))
results.append(result)
return {
"status": "bulk_sent",
"total": len(order_ids),
"successful": sum(1 for r in results if r.get("status") == "sent"),
"failed": sum(1 for r in results if r.get("status") == "failed"),
}
async def _get_customer_history(self, task: AgentTask) -> Dict[str, Any]:
customer_id = task.data.get("customer_id")
notifications = self._notifications.get(customer_id, [])
return {
"customer_id": customer_id,
"total_notifications": len(notifications),
"notifications": [self._format_notification(n) for n in notifications[-20:]],
}
async def _handle_query(self, task: AgentTask) -> Dict[str, Any]:
query = task.data.get("query", "").lower()
order_id = task.data.get("order_id")
logger.info(f"Customer Agent: Handling query '{query}' for order {order_id}")
if "where" in query or "track" in query or "status" in query:
return await self._get_tracking(AgentTask(
task_id="query_tracking",
agent_type="customer",
task_type="get_tracking",
data={"order_id": order_id},
))
if "delay" in query or "late" in query:
return {
"response": "I understand your concern about the delay. Let me check the current status and get back to you with updated information.",
"action": "check_status",
}
if "cancel" in query:
return {
"response": f"I can help you with cancellation. Please confirm you want to cancel order {order_id}. Note: Cancellation is only possible before dispatch.",
"action": "await_confirmation",
}
return {
"response": "I'm here to help! You can ask about your order status, tracking, delays, or cancellation. How can I assist you?",
"action": "provide_options",
}
async def _send_via_channel(
self,
order_id: str,
customer_id: str,
channel: NotificationChannel,
notification_type: NotificationType,
template_vars: Dict[str, Any],
) -> Dict[str, Any]:
"""Render template and call Go backend (POST /api/v1/internal/notify)."""
template = self._templates.get(notification_type, {}).get(channel.value, "")
message = template
for key, value in template_vars.items():
message = message.replace(f"{{{key}}}", str(value))
notification_id = f"NOTIF-{uuid.uuid4().hex[:8].upper()}"
result = await api_post(
f"{GO_API_BASE_URL}/api/v1/internal/notify",
json={
"notification_id": notification_id,
"order_id": order_id,
"customer_id": customer_id,
"channel": channel.value,
"notification_type": notification_type.value,
"message": message,
},
headers={"X-Internal-Key": INTERNAL_API_KEY},
timeout=aiohttp.ClientTimeout(total=10),
)
if result is None:
status = "failed"
logger.warning(f"Notify API call failed for {notification_id}")
else:
status = "sent"
notification = Notification(
notification_id=notification_id,
order_id=order_id,
customer_id=customer_id,
channel=channel,
notification_type=notification_type,
message=message,
sent_at=datetime.now(),
delivered_at=None,
status=status,
)
if customer_id not in self._notifications:
self._notifications[customer_id] = []
self._notifications[customer_id].append(notification)
logger.info(f"[{channel.value.upper()}] {notification_type.value} -> {customer_id[:20]} [{status}]")
return {
"notification_id": notification_id,
"channel": channel.value,
"status": status,
"message": message,
}
def _format_notification(self, notification: Notification) -> Dict[str, Any]:
return {
"id": notification.notification_id,
"type": notification.notification_type.value,
"channel": notification.channel.value,
"message": notification.message,
"sent_at": notification.sent_at.isoformat(),
"status": notification.status,
}
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
async def think(self, context: str, options: List[str] = None) -> str:
return f"[CUSTOMER_AGENT reasoning]: {context}"

379
agents/dispatch_agent.py Normal file
View File

@@ -0,0 +1,379 @@
"""Dispatch Agent - Handles zone analysis, route assignment, and order dispatching."""
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
import redis.asyncio as aioredis
from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType, Priority, ZoneType
from core.logger import logger
from core.http_client import api_post
from config.system_config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, GO_API_BASE_URL, INTERNAL_API_KEY
@dataclass
class ZoneInfo:
zone_id: str
zone_name: str
pincode_range: tuple
hub_id: str
vehicle_types: List[str]
avg_delivery_time: int
current_load: int = 0
max_capacity: int = 100
@dataclass
class RouteAssignment:
route_id: str
order_id: str
from_hub: str
to_hub: str
vehicle_id: Optional[str]
estimated_pickup: datetime
estimated_delivery: datetime
route_type: str
class DispatchAgent(SpecializedAgent):
"""
Dispatch Agent - Orchestrates order dispatching and route assignment.
Uses Redis GEO (GEORADIUS on milers:locations) to find nearest milers
for a given pickup/delivery coordinate. Falls back to pincode-based
zone lookup when no milers are found within range.
"""
def __init__(self):
super().__init__(
agent_id="DISPATCH_AGENT",
domain="dispatch",
description="Handles zone analysis, route assignment, and dispatch coordination"
)
self._redis = aioredis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD,
decode_responses=True,
)
self._zones = self._init_zones()
self._hubs = self._init_hubs()
self._active_routes: Dict[str, RouteAssignment] = {}
self._dispatch_queue: asyncio.Queue = asyncio.Queue()
def _init_zones(self) -> Dict[str, ZoneInfo]:
return {
"north_delhi": ZoneInfo("north_delhi", "North Delhi", ("100", "199"), "DL-HUB-01", ["bike", "scooter", "van"], 45),
"south_delhi": ZoneInfo("south_delhi", "South Delhi", ("200", "299"), "DL-HUB-02", ["bike", "scooter", "van"], 50),
"mumbai_west": ZoneInfo("mumbai_west", "West Mumbai", ("400", "449"), "MU-HUB-01", ["bike", "scooter", "van", "truck"], 60),
"mumbai_east": ZoneInfo("mumbai_east", "East Mumbai", ("450", "499"), "MU-HUB-02", ["bike", "scooter", "van", "truck"], 55),
"bangalore": ZoneInfo("bangalore", "Bangalore", ("560", "562"), "BL-HUB-01", ["bike", "scooter", "van"], 40),
"hyderabad": ZoneInfo("hyderabad", "Hyderabad", ("500", "509"), "HY-HUB-01", ["bike", "scooter", "van", "truck"], 50),
"pune": ZoneInfo("pune", "Pune", ("411", "415"), "PU-HUB-01", ["bike", "scooter", "van"], 45),
"kolkata": ZoneInfo("kolkata", "Kolkata", ("700", "700"), "KL-HUB-01", ["bike", "scooter", "van"], 55),
}
def _init_hubs(self) -> Dict[str, Dict]:
return {
"DL-HUB-01": {"name": "Delhi North Hub", "capacity": 500, "current_load": 150},
"DL-HUB-02": {"name": "Delhi South Hub", "capacity": 400, "current_load": 200},
"MU-HUB-01": {"name": "Mumbai West Hub", "capacity": 600, "current_load": 300},
"MU-HUB-02": {"name": "Mumbai East Hub", "capacity": 550, "current_load": 250},
"BL-HUB-01": {"name": "Bangalore Hub", "capacity": 500, "current_load": 180},
"HY-HUB-01": {"name": "Hyderabad Hub", "capacity": 450, "current_load": 220},
"PU-HUB-01": {"name": "Pune Hub", "capacity": 400, "current_load": 160},
"KL-HUB-01": {"name": "Kolkata Hub", "capacity": 350, "current_load": 140},
}
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
handlers = {
"analyze_and_assign": self._analyze_and_assign,
"find_route": self._find_route,
"optimize_routes": self._optimize_routes,
"assign_vehicle": self._assign_vehicle,
"get_zone_status": self._get_zone_status,
"reschedule_route": self._reschedule_route,
"cancel_route": self._cancel_route,
}
handler = handlers.get(task.task_type, self._unknown_task)
return await handler(task)
# ------------------------------------------------------------------ #
# Redis GEO zone lookup #
# ------------------------------------------------------------------ #
async def _find_zone(self, lat: float, lon: float) -> Optional[Dict[str, Any]]:
"""Find the nearest miler using GEORADIUS on milers:locations."""
try:
results = await self._redis.georadius(
"milers:locations",
lon, lat,
10, "km",
sort="ASC",
count=5,
)
if not results:
return None
nearest_miler = results[0]
miler_info = await self._redis.hgetall(f"miler:{nearest_miler}")
return {
"miler_id": nearest_miler,
"hub_id": miler_info.get("hub_id", "UNKNOWN"),
"zone_id": miler_info.get("zone_id", "unknown"),
"avg_delivery_time": int(miler_info.get("avg_delivery_time", 60)),
}
except Exception as e:
logger.warning(f"Redis GEORADIUS error: {e}")
return None
def _find_zone_by_pincode(self, pincode_prefix: str) -> Optional[ZoneInfo]:
for zone in self._zones.values():
start, end = zone.pincode_range
if start <= pincode_prefix <= end:
return zone
return None
# ------------------------------------------------------------------ #
# Core dispatch logic #
# ------------------------------------------------------------------ #
async def _analyze_and_assign(self, task: AgentTask) -> Dict[str, Any]:
order_data = task.data.get("order", {})
order_id = order_data.get("order_id", "unknown")
logger.info(f"Dispatch Agent: Analyzing order {order_id}")
pickup_addr = order_data.get("pickup_address", {})
delivery_addr = order_data.get("delivery_address", {})
pickup_lat = pickup_addr.get("lat") or pickup_addr.get("latitude")
pickup_lon = pickup_addr.get("lng") or pickup_addr.get("longitude")
delivery_lat = delivery_addr.get("lat") or delivery_addr.get("latitude")
delivery_lon = delivery_addr.get("lng") or delivery_addr.get("longitude")
pickup_zone_geo = await self._find_zone(pickup_lat, pickup_lon) if (pickup_lat and pickup_lon) else None
delivery_zone_geo = await self._find_zone(delivery_lat, delivery_lon) if (delivery_lat and delivery_lon) else None
pickup_pincode = pickup_addr.get("pincode", "")[:3]
delivery_pincode = delivery_addr.get("pincode", "")[:3]
pickup_zone_pc = self._find_zone_by_pincode(pickup_pincode)
delivery_zone_pc = self._find_zone_by_pincode(delivery_pincode)
from_hub = (
pickup_zone_geo.get("hub_id") if pickup_zone_geo
else (pickup_zone_pc.hub_id if pickup_zone_pc else "UNKNOWN")
)
to_hub = (
delivery_zone_geo.get("hub_id") if delivery_zone_geo
else (delivery_zone_pc.hub_id if delivery_zone_pc else "UNKNOWN")
)
if pickup_pincode == delivery_pincode:
route_type = ZoneType.LAST_MILE.value
elif from_hub == to_hub:
route_type = ZoneType.HUB_TO_SPOKE.value
else:
route_type = ZoneType.HUB_TO_HUB.value
avg_time = (
(pickup_zone_geo or {}).get("avg_delivery_time") or
(pickup_zone_pc.avg_delivery_time if pickup_zone_pc else 60)
)
now = datetime.now()
estimated_pickup = now + timedelta(minutes=30)
estimated_delivery = now + timedelta(minutes=avg_time * 2)
route_id = f"RT-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
route_assignment = RouteAssignment(
route_id=route_id,
order_id=order_id,
from_hub=from_hub,
to_hub=to_hub,
vehicle_id=None,
estimated_pickup=estimated_pickup,
estimated_delivery=estimated_delivery,
route_type=route_type,
)
self._active_routes[route_id] = route_assignment
logger.info(
f"Route assigned: {from_hub} -> {to_hub} | type={route_type} "
f"pickup={estimated_pickup.strftime('%H:%M')} delivery={estimated_delivery.strftime('%H:%M')}"
)
# Assign miler in Postgres via Go internal API.
# booking_id is the Go API's integer ID (set by order_agent after FIX 4).
booking_id = order_data.get("booking_id") or order_id
nearest_miler = (pickup_zone_geo or {}).get("miler_id")
if nearest_miler:
logger.debug(f"Nearest miler from GEORADIUS: {nearest_miler}")
assign_result = await api_post(
f"{GO_API_BASE_URL}/api/v1/internal/bookings/{booking_id}/reassign",
json={"reason": "initial_assignment"},
headers={"X-Internal-Key": INTERNAL_API_KEY},
)
if assign_result is not None:
logger.info(f"Miler assigned to booking {booking_id} via Go API")
else:
logger.warning(f"Go API miler assignment failed for booking {booking_id} — booking may stay unassigned")
await self.send_message(
recipient="HUB_AGENT",
message_type=MessageType.AGENT_TASK,
payload={"task_type": "prepare_receiving", "route_id": route_id, "hub_id": to_hub, "order_id": order_id},
correlation_id=order_id,
)
await self.send_message(
recipient="CUSTOMER_AGENT",
message_type=MessageType.NOTIFICATION_SENT,
payload={
"order_id": order_id,
"message_type": "dispatch_assigned",
"data": {
"route_id": route_id,
"estimated_pickup": estimated_pickup.isoformat(),
"estimated_delivery": estimated_delivery.isoformat(),
"route_type": route_type,
},
},
correlation_id=order_id,
)
return {
"status": "assigned",
"route_id": route_id,
"order_id": order_id,
"booking_id": booking_id,
"from_hub": from_hub,
"to_hub": to_hub,
"route_type": route_type,
"nearest_miler": nearest_miler,
"miler_assigned": assign_result is not None,
"estimated_pickup": estimated_pickup.isoformat(),
"estimated_delivery": estimated_delivery.isoformat(),
}
async def _find_route(self, task: AgentTask) -> Dict[str, Any]:
from_pincode = task.data.get("from_pincode", "")[:3]
to_pincode = task.data.get("to_pincode", "")[:3]
from_zone = self._find_zone_by_pincode(from_pincode)
to_zone = self._find_zone_by_pincode(to_pincode)
if not from_zone or not to_zone:
return {"status": "error", "message": "Zone not found for one or both pincodes"}
route_type = (
ZoneType.HUB_TO_SPOKE.value
if from_zone.zone_id == to_zone.zone_id
else ZoneType.HUB_TO_HUB.value
)
return {
"status": "found",
"from_hub": from_zone.hub_id,
"to_hub": to_zone.hub_id,
"route_type": route_type,
"estimated_time_minutes": from_zone.avg_delivery_time + to_zone.avg_delivery_time,
}
async def _optimize_routes(self, task: AgentTask) -> Dict[str, Any]:
order_ids = task.data.get("order_ids", [])
logger.info(f"Dispatch Agent: Optimizing {len(order_ids)} routes")
zone_groups: Dict[str, List[str]] = {}
for order_id in order_ids:
for route in self._active_routes.values():
if route.order_id == order_id:
zone_groups.setdefault(route.from_hub, []).append(order_id)
optimized = [
{"zone": zone, "orders": orders, "saved_km": len(orders) * 2.5}
for zone, orders in zone_groups.items()
]
return {"status": "optimized", "groups": optimized, "total_orders": len(order_ids)}
async def _assign_vehicle(self, task: AgentTask) -> Dict[str, Any]:
route_id = task.data.get("route_id")
if route_id not in self._active_routes:
return {"status": "error", "message": f"Route {route_id} not found"}
self._active_routes[route_id].vehicle_id = task.data.get("vehicle_id")
return {"status": "vehicle_assigned", "route_id": route_id}
async def _get_zone_status(self, task: AgentTask) -> Dict[str, Any]:
zone_status = []
for zone_id, zone in self._zones.items():
hub = self._hubs.get(zone.hub_id, {})
zone_status.append({
"zone_id": zone_id,
"zone_name": zone.zone_name,
"hub_id": zone.hub_id,
"current_load": hub.get("current_load", 0),
"max_capacity": hub.get("capacity", 0),
"utilization_percent": (hub.get("current_load", 0) / max(hub.get("capacity", 1), 1)) * 100,
})
return {"zones": zone_status, "total_zones": len(zone_status)}
async def _reschedule_route(self, task: AgentTask) -> Dict[str, Any]:
route_id = task.data.get("route_id")
if route_id not in self._active_routes:
return {"status": "error", "message": f"Route {route_id} not found"}
route = self._active_routes[route_id]
if task.data.get("new_pickup_time"):
route.estimated_pickup = datetime.fromisoformat(task.data["new_pickup_time"])
if task.data.get("new_delivery_time"):
route.estimated_delivery = datetime.fromisoformat(task.data["new_delivery_time"])
await self.send_message(
recipient="CUSTOMER_AGENT",
message_type=MessageType.NOTIFICATION_SENT,
payload={
"order_id": route.order_id,
"message_type": "schedule_changed",
"data": {
"new_pickup": route.estimated_pickup.isoformat(),
"new_delivery": route.estimated_delivery.isoformat(),
},
},
correlation_id=route_id,
)
return {
"status": "rescheduled",
"route_id": route_id,
"new_pickup": route.estimated_pickup.isoformat(),
"new_delivery": route.estimated_delivery.isoformat(),
}
async def _cancel_route(self, task: AgentTask) -> Dict[str, Any]:
route_id = task.data.get("route_id")
reason = task.data.get("reason", "Route cancelled")
if route_id not in self._active_routes:
return {"status": "error", "message": f"Route {route_id} not found"}
route = self._active_routes.pop(route_id)
await self.send_message(
recipient="FLEET_AGENT",
message_type=MessageType.AGENT_TASK,
payload={"task_type": "release_vehicle", "vehicle_id": route.vehicle_id},
)
return {"status": "cancelled", "route_id": route_id, "reason": reason}
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
async def think(self, context: str, options: List[str] = None) -> str:
return f"[DISPATCH_AGENT reasoning]: {context}"

607
agents/exception_agent.py Normal file
View File

@@ -0,0 +1,607 @@
"""
Exception Agent — Miler stall detection + general exception handling.
Stall detection flow:
JetStream TRACKING/miler.location.updated (pull) -> Redis HSET -> stall check -> miler.stalled
JetStream TRACKING/miler.stalled (pull) -> POST reassign + POST notify
StallDetector sweep (every 60 s):
Postgres pickupbookings (active status) -> Redis movement.updated_at age -> miler.stalled
"""
import asyncio
import json
import uuid
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional, Set
from dataclasses import dataclass, field
from enum import Enum
import asyncpg
import nats
import nats.errors
import redis.asyncio as aioredis
from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType
from core.logger import logger
from core.http_client import api_post
from config.system_config import (
GO_API_BASE_URL, INTERNAL_API_KEY,
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD,
NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD,
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD,
)
STALL_MINUTES = 10
ACTIVE_STATUSES = ["Miler_Assigned", "Pickup_Scheduled"]
TRACKING_STREAM = "TRACKING"
class ExceptionType(str, Enum):
DELAY = "delay"
CANCELLATION = "cancellation"
RESCHEDULE = "reschedule"
REROUTE = "reroute"
FAILED_DELIVERY = "failed_delivery"
VEHICLE_BREAKDOWN = "vehicle_breakdown"
HUB_OVERFLOW = "hub_overflow"
WEATHER_IMPACT = "weather_impact"
CUSTOMER_UNAVAILABLE = "customer_unavailable"
MILER_STALLED = "miler_stalled"
class ExceptionSeverity(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
@dataclass
class ExceptionRecord:
exception_id: str
order_id: str
exception_type: ExceptionType
severity: ExceptionSeverity
description: str
detected_at: datetime
resolved_at: Optional[datetime]
resolution: Optional[str]
assigned_to: Optional[str]
status: str
actions_taken: List[str] = field(default_factory=list)
class ExceptionAgent(SpecializedAgent):
"""
Handles miler stall detection via JetStream pull-subscribe + Postgres + Redis,
and all existing order exception types.
"""
def __init__(self):
super().__init__(
agent_id="EXCEPTION_AGENT",
domain="exception_handling",
description="Stall detection, exception resolution, reassignment",
)
self._nc: Optional[nats.aio.client.Client] = None
self._js = None
self._pg: Optional[asyncpg.Pool] = None
self._redis = aioredis.Redis(
host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD,
decode_responses=True,
)
self._location_sub = None
self._stalled_sub = None
self._notified_stalls: Set[str] = set()
self._exceptions: Dict[str, ExceptionRecord] = {}
self._strategies = self._init_strategies()
self._escalation_rules = self._init_escalation_rules()
# ── Startup ───────────────────────────────────────────────────────────────
async def start(self):
await self._connect_infra()
await self._setup_nats_pull_subs()
asyncio.create_task(self._pull_location_loop())
asyncio.create_task(self._pull_stalled_loop())
asyncio.create_task(self._stall_detector_loop())
await super().start()
async def _connect_infra(self):
try:
self._nc = await nats.connect(
servers=[f"nats://{NATS_HOST}:{NATS_PORT}"],
user=NATS_USER,
password=NATS_PASSWORD,
max_reconnect_attempts=10,
)
self._js = self._nc.jetstream()
logger.info("EXCEPTION_AGENT connected to NATS JetStream")
except Exception as e:
logger.warning(f"EXCEPTION_AGENT NATS connect failed: {e}")
try:
self._pg = await asyncpg.create_pool(
host=DB_HOST, port=DB_PORT, database=DB_NAME,
user=DB_USER, password=DB_PASSWORD,
min_size=1, max_size=5,
)
logger.info("EXCEPTION_AGENT connected to Postgres")
except Exception as e:
logger.warning(f"EXCEPTION_AGENT Postgres connect failed: {e}")
async def _setup_nats_pull_subs(self):
if not self._js:
logger.warning("EXCEPTION_AGENT: skipping NATS pull subs (no JetStream connection)")
return
try:
self._location_sub = await self._js.pull_subscribe(
"miler.location.updated",
"tracking_location_consumer",
stream=TRACKING_STREAM,
)
logger.info("EXCEPTION_AGENT pull-subscribed: miler.location.updated")
except Exception as e:
logger.warning(f"pull_subscribe miler.location.updated failed: {e}")
try:
self._stalled_sub = await self._js.pull_subscribe(
"miler.stalled",
"tracking_stall_consumer",
stream=TRACKING_STREAM,
)
logger.info("EXCEPTION_AGENT pull-subscribed: miler.stalled")
except Exception as e:
logger.warning(f"pull_subscribe miler.stalled failed: {e}")
# ── Pull loops ────────────────────────────────────────────────────────────
async def _pull_location_loop(self):
while self._running:
if not self._location_sub:
await asyncio.sleep(5)
continue
try:
msgs = await self._location_sub.fetch(batch=20, timeout=1.0)
for msg in msgs:
try:
await self._on_location_update(msg)
except Exception as e:
logger.error(f"Location handler error: {e}")
finally:
await msg.ack()
except nats.errors.TimeoutError:
pass
except Exception as e:
logger.error(f"Location pull loop error: {e}")
await asyncio.sleep(2)
async def _pull_stalled_loop(self):
while self._running:
if not self._stalled_sub:
await asyncio.sleep(5)
continue
try:
msgs = await self._stalled_sub.fetch(batch=10, timeout=1.0)
for msg in msgs:
try:
await self._on_miler_stalled(msg)
except Exception as e:
logger.error(f"Stalled handler error: {e}")
finally:
await msg.ack()
except nats.errors.TimeoutError:
pass
except Exception as e:
logger.error(f"Stalled pull loop error: {e}")
await asyncio.sleep(2)
# ── Stall detection: per location ping ───────────────────────────────────
async def _on_location_update(self, msg):
try:
data = json.loads(msg.data.decode())
except Exception:
return
miler_id = str(data.get("miler_id", ""))
lat = str(round(float(data.get("lat", 0)), 6))
lon = str(round(float(data.get("lon", 0)), 6))
now_ts = datetime.now().isoformat()
redis_key = f"miler:{miler_id}:movement"
prev = await self._redis.hgetall(redis_key)
prev_lat = prev.get("lat", "")
prev_lon = prev.get("lon", "")
if lat != prev_lat or lon != prev_lon:
await self._redis.hset(redis_key, mapping={
"lat": lat, "lon": lon,
"updated_at": now_ts,
"position_unchanged_since": now_ts,
})
else:
await self._redis.hset(redis_key, mapping={"lat": lat, "lon": lon, "updated_at": now_ts})
unchanged_since_str = prev.get("position_unchanged_since", now_ts)
try:
unchanged_since = datetime.fromisoformat(unchanged_since_str)
except ValueError:
unchanged_since = datetime.now()
minutes_stalled = (datetime.now() - unchanged_since).total_seconds() / 60
if minutes_stalled >= STALL_MINUTES:
booking = await self._get_active_booking(miler_id)
if booking:
booking_id = booking["booking_id"]
if booking_id not in self._notified_stalls:
await self._publish_stall(miler_id, booking_id, minutes_stalled)
# ── Stall detection: background sweep ────────────────────────────────────
async def _stall_detector_loop(self):
while self._running:
await asyncio.sleep(60)
try:
await self._sweep_active_bookings()
except Exception as e:
logger.error(f"StallDetector error: {e}")
async def _sweep_active_bookings(self):
if not self._pg:
return
logger.debug("StallDetector: sweeping active bookings")
try:
async with self._pg.acquire() as conn:
rows = await conn.fetch(
"""
SELECT DISTINCT
assignedmileruserid::text AS miler_id,
bookingid::text AS booking_id
FROM pickupbookings
WHERE status = ANY($1)
""",
ACTIVE_STATUSES,
)
except Exception as e:
logger.error(f"StallDetector Postgres error: {e}")
return
now = datetime.now()
for row in rows:
miler_id = row["miler_id"]
booking_id = row["booking_id"]
if booking_id in self._notified_stalls:
continue
movement = await self._redis.hgetall(f"miler:{miler_id}:movement")
if not movement or "updated_at" not in movement:
continue
try:
updated_at = datetime.fromisoformat(movement["updated_at"])
minutes_stale = (now - updated_at).total_seconds() / 60
except ValueError:
continue
if minutes_stale >= STALL_MINUTES:
logger.warning(f"StallDetector: miler {miler_id} stale {minutes_stale:.1f} min (booking {booking_id})")
await self._publish_stall(miler_id, booking_id, minutes_stale)
# ── Stall: publish + handle ───────────────────────────────────────────────
async def _publish_stall(self, miler_id: str, booking_id: str, minutes_stalled: float):
self._notified_stalls.add(booking_id)
payload = json.dumps({
"miler_id": miler_id,
"booking_id": booking_id,
"minutes_stalled": round(minutes_stalled, 1),
}).encode()
try:
await self._js.publish("miler.stalled", payload)
logger.info(f"miler.stalled published: miler={miler_id} booking={booking_id} ({minutes_stalled:.1f} min)")
except Exception as e:
logger.error(f"Failed to publish miler.stalled: {e}")
async def _on_miler_stalled(self, msg):
"""Reassign booking and notify customer via Go API."""
try:
data = json.loads(msg.data.decode())
except Exception:
return
miler_id = data.get("miler_id", "")
booking_id = data.get("booking_id", "")
minutes_stalled = data.get("minutes_stalled", 0)
logger.warning(f"EXCEPTION_AGENT stall: miler={miler_id} booking={booking_id} ({minutes_stalled} min)")
headers = {"X-Internal-Key": INTERNAL_API_KEY}
reassign_result = await api_post(
f"{GO_API_BASE_URL}/api/v1/internal/bookings/{booking_id}/reassign",
json={"reason": "miler_stalled"},
headers=headers,
)
logger.info(f"Reassign {'OK' if reassign_result is not None else 'FAILED'} for booking {booking_id}")
notify_result = await api_post(
f"{GO_API_BASE_URL}/api/v1/internal/notify",
json={
"booking_id": booking_id,
"message": "We detected a delay, finding you a new miler",
"target": "customer",
},
headers=headers,
)
logger.info(f"Notify {'OK' if notify_result is not None else 'FAILED'} for booking {booking_id}")
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
self._exceptions[exc_id] = ExceptionRecord(
exception_id=exc_id, order_id=booking_id,
exception_type=ExceptionType.MILER_STALLED,
severity=ExceptionSeverity.HIGH,
description=f"Miler {miler_id} stalled {minutes_stalled} min",
detected_at=datetime.now(), resolved_at=datetime.now(),
resolution="Reassignment triggered + customer notified",
assigned_to=None, status="resolved",
actions_taken=["reassign", "notify_customer"],
)
# ── Postgres helper ───────────────────────────────────────────────────────
async def _get_active_booking(self, miler_id: str) -> Optional[Dict]:
if not self._pg:
return None
try:
async with self._pg.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT bookingid::text AS booking_id, status
FROM pickupbookings
WHERE assignedmileruserid::text = $1
AND status = ANY($2)
ORDER BY createdat DESC
LIMIT 1
""",
miler_id,
ACTIVE_STATUSES,
)
if row:
return {"booking_id": row["booking_id"], "status": row["status"]}
except Exception as e:
logger.error(f"Postgres get_active_booking error: {e}")
return None
# ── Task handler ──────────────────────────────────────────────────────────
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
handlers = {
"detect_exception": self._detect_exception,
"analyze_exception": self._analyze_exception,
"resolve_exception": self._resolve_exception,
"cancel_order": self._cancel_order,
"reschedule_delivery": self._reschedule_delivery,
"handle_delay": self._handle_delay,
"get_exception_status": self._get_exception_status,
"get_exception_history": self._get_exception_history,
"escalate_exception": self._escalate_exception,
}
handler = handlers.get(task.task_type, self._unknown_task)
return await handler(task)
async def _detect_exception(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
exception_type = task.data.get("exception_type")
severity = task.data.get("severity", "medium")
description = task.data.get("description", "")
logger.warning(f"Exception Agent: {exception_type} for {order_id} (severity={severity})")
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
record = ExceptionRecord(
exception_id=exc_id, order_id=order_id,
exception_type=ExceptionType(exception_type),
severity=ExceptionSeverity(severity), description=description,
detected_at=datetime.now(), resolved_at=None,
resolution=None, assigned_to=None, status="detected",
)
self._exceptions[exc_id] = record
if severity in ("high", "critical"):
await self._analyze_exception(AgentTask(
task_id=f"{exc_id}_analyze", agent_type="exception",
task_type="analyze_exception", data={"exception_id": exc_id},
))
await self.send_message(
recipient="JARVIS", message_type=MessageType.EXCEPTION_DETECTED,
payload={"exception_id": exc_id, "order_id": order_id,
"exception_type": exception_type, "severity": severity},
)
return {"status": "detected", "exception_id": exc_id,
"exception_type": exception_type, "severity": severity}
async def _analyze_exception(self, task: AgentTask) -> Dict[str, Any]:
exc_id = task.data.get("exception_id")
if exc_id not in self._exceptions:
return {"status": "error", "message": f"Exception {exc_id} not found"}
record = self._exceptions[exc_id]
record.status = "analyzing"
strategy = self._strategies.get(record.exception_type, {})
auto_actions = strategy.get("auto_actions", [])
record.actions_taken.append("analyzed")
return {"status": "analyzed", "exception_id": exc_id, "strategy": auto_actions}
async def _resolve_exception(self, task: AgentTask) -> Dict[str, Any]:
exc_id = task.data.get("exception_id")
if exc_id not in self._exceptions:
return {"status": "error", "message": f"Exception {exc_id} not found"}
record = self._exceptions[exc_id]
record.status = "resolving"
for action in self._strategies.get(record.exception_type, {}).get("auto_actions", []):
record.actions_taken.append(action)
record.status = "resolved"
record.resolved_at = datetime.now()
record.resolution = f"Completed {len(record.actions_taken)} actions"
await self.send_message(
recipient="CUSTOMER_AGENT", message_type=MessageType.NOTIFICATION_SENT,
payload={"order_id": record.order_id, "message_type": "exception_resolved",
"data": {"exception_type": record.exception_type.value}},
)
return {"status": "resolved", "exception_id": exc_id, "resolution": record.resolution}
async def _cancel_order(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
reason = task.data.get("reason", "Customer requested cancellation")
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
self._exceptions[exc_id] = ExceptionRecord(
exception_id=exc_id, order_id=order_id,
exception_type=ExceptionType.CANCELLATION, severity=ExceptionSeverity.MEDIUM,
description=reason, detected_at=datetime.now(), resolved_at=datetime.now(),
resolution="Order cancelled", assigned_to=None, status="resolved",
actions_taken=["release_vehicle", "notify_customer"],
)
await self.send_message(
recipient="FLEET_AGENT", message_type=MessageType.AGENT_TASK,
payload={"task_type": "release_vehicle_for_cancel", "order_id": order_id},
)
await self.send_message(
recipient="CUSTOMER_AGENT", message_type=MessageType.ORDER_CANCELLED,
payload={"order_id": order_id, "reason": reason,
"refund_info": "Refund within 5-7 business days"},
)
return {"status": "cancelled", "exception_id": exc_id, "order_id": order_id}
async def _reschedule_delivery(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
new_date = task.data.get("new_date")
reason = task.data.get("reason", "Operational constraints")
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
self._exceptions[exc_id] = ExceptionRecord(
exception_id=exc_id, order_id=order_id,
exception_type=ExceptionType.RESCHEDULE, severity=ExceptionSeverity.MEDIUM,
description=f"Rescheduled to {new_date}: {reason}",
detected_at=datetime.now(), resolved_at=None, resolution=None,
assigned_to=None, status="resolving",
)
await self.send_message(
recipient="DISPATCH_AGENT", message_type=MessageType.AGENT_TASK,
payload={"task_type": "reschedule_route", "order_id": order_id, "new_date": new_date},
)
await self.send_message(
recipient="CUSTOMER_AGENT", message_type=MessageType.NOTIFICATION_SENT,
payload={"order_id": order_id, "message_type": "rescheduled",
"data": {"new_date": new_date, "reason": reason}},
)
return {"status": "rescheduled", "exception_id": exc_id, "order_id": order_id, "new_date": new_date}
async def _handle_delay(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
delay_reason = task.data.get("reason", "Unknown")
new_eta = task.data.get("new_eta")
severity = task.data.get("severity", "medium")
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
self._exceptions[exc_id] = ExceptionRecord(
exception_id=exc_id, order_id=order_id,
exception_type=ExceptionType.DELAY, severity=ExceptionSeverity(severity),
description=f"Delay: {delay_reason}", detected_at=datetime.now(),
resolved_at=None, resolution=None, assigned_to=None, status="resolving",
)
if new_eta:
await self.send_message(
recipient="DISPATCH_AGENT", message_type=MessageType.ORDER_DELAYED,
payload={"order_id": order_id, "new_eta": new_eta, "reason": delay_reason},
)
await self.send_message(
recipient="CUSTOMER_AGENT", message_type=MessageType.NOTIFICATION_SENT,
payload={"order_id": order_id, "message_type": "delayed",
"data": {"delay_reason": delay_reason, "new_eta": new_eta}},
)
return {"status": "delay_handled", "exception_id": exc_id,
"order_id": order_id, "new_eta": new_eta, "customer_notified": True}
async def _get_exception_status(self, task: AgentTask) -> Dict[str, Any]:
exc_id = task.data.get("exception_id")
if exc_id not in self._exceptions:
return {"status": "error", "message": f"Exception {exc_id} not found"}
r = self._exceptions[exc_id]
return {
"exception_id": exc_id, "order_id": r.order_id,
"type": r.exception_type.value, "severity": r.severity.value,
"status": r.status, "description": r.description,
"detected_at": r.detected_at.isoformat(),
"resolved_at": r.resolved_at.isoformat() if r.resolved_at else None,
"actions_taken": r.actions_taken,
}
async def _get_exception_history(self, task: AgentTask) -> Dict[str, Any]:
exc_type = task.data.get("exception_type")
status_f = task.data.get("status")
items = list(self._exceptions.values())
if exc_type:
items = [e for e in items if e.exception_type.value == exc_type]
if status_f:
items = [e for e in items if e.status == status_f]
return {
"total_exceptions": len(items),
"exceptions": [
{"exception_id": e.exception_id, "order_id": e.order_id,
"type": e.exception_type.value, "severity": e.severity.value,
"status": e.status, "detected_at": e.detected_at.isoformat()}
for e in items[-50:]
],
}
async def _escalate_exception(self, task: AgentTask) -> Dict[str, Any]:
exc_id = task.data.get("exception_id")
if exc_id not in self._exceptions:
return {"status": "error", "message": f"Exception {exc_id} not found"}
record = self._exceptions[exc_id]
escalate_to = self._escalation_rules.get(record.exception_type, {}).get("escalate_to", "JARVIS")
record.actions_taken.append(f"escalated_to_{escalate_to}")
await self.send_message(
recipient=escalate_to, message_type=MessageType.EXCEPTION_DETECTED,
payload={"exception_id": exc_id, "order_id": record.order_id,
"exception_type": record.exception_type.value,
"severity": record.severity.value, "urgency": "high"},
)
return {"status": "escalated", "exception_id": exc_id, "escalated_to": escalate_to}
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
def _init_strategies(self) -> Dict[str, Any]:
return {
ExceptionType.DELAY: {"auto_actions": ["notify_customer", "update_eta", "reroute_if_possible"]},
ExceptionType.CANCELLATION: {"auto_actions": ["release_vehicle", "refund_check", "notify_customer"]},
ExceptionType.RESCHEDULE: {"auto_actions": ["find_new_slot", "update_route", "notify_customer"]},
ExceptionType.REROUTE: {"auto_actions": ["recalculate_route", "update_vehicle", "notify_customer"]},
ExceptionType.FAILED_DELIVERY: {"auto_actions": ["retry_attempt", "return_to_hub"]},
ExceptionType.VEHICLE_BREAKDOWN: {"auto_actions": ["find_replacement_vehicle", "reassign_route", "notify_customer"]},
ExceptionType.HUB_OVERFLOW: {"auto_actions": ["reroute_to_alternate", "increase_processing"]},
ExceptionType.WEATHER_IMPACT: {"auto_actions": ["delay_notification", "route_avoidance"]},
ExceptionType.MILER_STALLED: {"auto_actions": ["reassign", "notify_customer"]},
}
def _init_escalation_rules(self) -> Dict[str, Any]:
return {
ExceptionType.VEHICLE_BREAKDOWN: {"escalate_to": "FLEET_AGENT"},
ExceptionType.HUB_OVERFLOW: {"escalate_to": "HUB_AGENT"},
ExceptionType.FAILED_DELIVERY: {"escalate_to": "DISPATCH_AGENT"},
ExceptionType.DELAY: {"escalate_to": "JARVIS"},
ExceptionType.MILER_STALLED: {"escalate_to": "JARVIS"},
}
async def think(self, context: str, options: List[str] = None) -> str:
return f"[EXCEPTION_AGENT reasoning]: {context}"

332
agents/fleet_agent.py Normal file
View File

@@ -0,0 +1,332 @@
"""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,
"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]
vehicle["status"] = "assigned"
if 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]
vehicle["status"] = "available"
if vehicle["hub"] in self._hub_capacity:
self._hub_capacity[vehicle["hub"]]["available"] += 1
logger.info(f"Vehicle {vehicle_id} released")
return {"status": "released", "vehicle_id": vehicle_id, "hub": vehicle["hub"]}
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)
self._vehicles[vehicle_id]["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}"

460
agents/hub_agent.py Normal file
View File

@@ -0,0 +1,460 @@
"""Hub Agent - Manages hub operations, transit, and capacity."""
import uuid
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
from collections import deque
from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType, Hub
from core.logger import logger
@dataclass
class TransitRecord:
record_id: str
order_id: str
entry_time: datetime
expected_exit: datetime
actual_exit: Optional[datetime]
destination: str
status: str # waiting, processing, in_transit, delivered
items_count: int
@dataclass
class HubInventory:
hub_id: str
current_load: int
capacity: int
incoming_transits: int
outgoing_transits: int
pending_processing: int
class HubAgent(SpecializedAgent):
"""Hub Agent - Manages hub operations and transit flow."""
def __init__(self):
super().__init__(
agent_id="HUB_AGENT",
domain="hub_management",
description="Manages hub operations, transit flow, and capacity"
)
self._hubs = self._init_hubs()
self._in_transit: Dict[str, List[TransitRecord]] = {h: [] for h in self._hubs.keys()}
self._processing_queues: Dict[str, deque] = {h: deque() for h in self._hubs.keys()}
self._metrics: Dict[str, Dict] = {}
# BUG FIX: metrics init was dead code inside _init_hubs (after return).
# Moved here so it actually runs after _hubs is populated.
for hub_id in self._hubs.keys():
self._metrics[hub_id] = {
"processed_today": 0,
"average_processing_time": 15,
"efficiency": 95.0,
"bottlenecks": [],
}
def _init_hubs(self) -> Dict[str, Dict]:
return {
"DL-HUB-01": {
"name": "Delhi North Hub",
"location": {"lat": 28.6139, "lng": 77.2090},
"address": "Sector 12, Narela, Delhi",
"capacity": 500, "current_load": 150,
"spokes": ["DL-SP-01", "DL-SP-02", "DL-SP-03"],
"connected_hubs": ["DL-HUB-02", "MU-HUB-01", "KL-HUB-01"],
"processing_rate": 100, "operating_hours": "24/7",
},
"DL-HUB-02": {
"name": "Delhi South Hub",
"location": {"lat": 28.5355, "lng": 77.2100},
"address": "Nehru Place, Delhi",
"capacity": 400, "current_load": 200,
"spokes": ["DL-SP-04", "DL-SP-05"],
"connected_hubs": ["DL-HUB-01", "BL-HUB-01"],
"processing_rate": 80, "operating_hours": "24/7",
},
"MU-HUB-01": {
"name": "Mumbai West Hub",
"location": {"lat": 19.0760, "lng": 72.8777},
"address": "Andheri West, Mumbai",
"capacity": 600, "current_load": 300,
"spokes": ["MU-SP-01", "MU-SP-02", "MU-SP-03", "MU-SP-04"],
"connected_hubs": ["MU-HUB-02", "DL-HUB-01", "PU-HUB-01"],
"processing_rate": 120, "operating_hours": "24/7",
},
"MU-HUB-02": {
"name": "Mumbai East Hub",
"location": {"lat": 19.1650, "lng": 72.8500},
"address": "Thane, Mumbai",
"capacity": 550, "current_load": 250,
"spokes": ["MU-SP-05", "MU-SP-06"],
"connected_hubs": ["MU-HUB-01", "PU-HUB-01"],
"processing_rate": 90, "operating_hours": "24/7",
},
"BL-HUB-01": {
"name": "Bangalore Hub",
"location": {"lat": 12.9716, "lng": 77.5946},
"address": "Whitefield, Bangalore",
"capacity": 500, "current_load": 180,
"spokes": ["BL-SP-01", "BL-SP-02", "BL-SP-03"],
"connected_hubs": ["HY-HUB-01", "DL-HUB-02"],
"processing_rate": 100, "operating_hours": "24/7",
},
"HY-HUB-01": {
"name": "Hyderabad Hub",
"location": {"lat": 17.3850, "lng": 78.4867},
"address": "Hi-Tech City, Hyderabad",
"capacity": 450, "current_load": 220,
"spokes": ["HY-SP-01", "HY-SP-02"],
"connected_hubs": ["BL-HUB-01", "KL-HUB-01"],
"processing_rate": 85, "operating_hours": "24/7",
},
"PU-HUB-01": {
"name": "Pune Hub",
"location": {"lat": 18.5204, "lng": 73.8567},
"address": "Hinjewadi, Pune",
"capacity": 400, "current_load": 160,
"spokes": ["PU-SP-01", "PU-SP-02"],
"connected_hubs": ["MU-HUB-01", "MU-HUB-02"],
"processing_rate": 75, "operating_hours": "24/7",
},
"KL-HUB-01": {
"name": "Kolkata Hub",
"location": {"lat": 22.5726, "lng": 88.3639},
"address": "Salt Lake, Kolkata",
"capacity": 350, "current_load": 140,
"spokes": ["KL-SP-01", "KL-SP-02"],
"connected_hubs": ["DL-HUB-01", "HY-HUB-01"],
"processing_rate": 70, "operating_hours": "24/7",
},
}
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
handlers = {
"prepare_receiving": self._prepare_receiving,
"process_incoming": self._process_incoming,
"dispatch_outgoing": self._dispatch_outgoing,
"get_hub_status": self._get_hub_status,
"transfer_between_hubs": self._transfer_between_hubs,
"get_transit_status": self._get_transit_status,
"optimize_hub_operations": self._optimize_hub_operations,
"escalate_overflow": self._escalate_overflow,
}
handler = handlers.get(task.task_type, self._unknown_task)
return await handler(task)
async def _prepare_receiving(self, task: AgentTask) -> Dict[str, Any]:
hub_id = task.data.get("hub_id")
order_id = task.data.get("order_id")
route_id = task.data.get("route_id")
if hub_id not in self._hubs:
return {"status": "error", "message": f"Hub {hub_id} not found"}
hub = self._hubs[hub_id]
available_capacity = hub["capacity"] - hub["current_load"]
logger.info(f"Hub Agent: Preparing {hub['name']} to receive order {order_id}")
if available_capacity < 10:
await self.send_message(
recipient="JARVIS",
message_type=MessageType.EXCEPTION_DETECTED,
payload={
"type": "capacity_warning",
"hub_id": hub_id,
"current_load": hub["current_load"],
"capacity": hub["capacity"],
"order_id": order_id,
},
)
logger.warning(f"Hub {hub_id} near capacity: {hub['current_load']}/{hub['capacity']}")
self._processing_queues[hub_id].append({
"order_id": order_id,
"route_id": route_id,
"arrival_time": datetime.now(),
"priority": "normal",
})
return {
"status": "prepared",
"hub_id": hub_id,
"hub_name": hub["name"],
"queue_position": len(self._processing_queues[hub_id]),
"estimated_wait": len(self._processing_queues[hub_id]) * 5,
}
async def _process_incoming(self, task: AgentTask) -> Dict[str, Any]:
hub_id = task.data.get("hub_id")
order_id = task.data.get("order_id")
if hub_id not in self._hubs:
return {"status": "error", "message": f"Hub {hub_id} not found"}
hub = self._hubs[hub_id]
hub["current_load"] += 1
record_id = f"TR-{hub_id}-{uuid.uuid4().hex[:6].upper()}"
record = TransitRecord(
record_id=record_id,
order_id=order_id,
entry_time=datetime.now(),
expected_exit=datetime.now() + timedelta(minutes=15), # 15 min avg processing
actual_exit=None,
destination=task.data.get("destination", "unknown"),
status="processing",
items_count=task.data.get("items_count", 1),
)
self._in_transit[hub_id].append(record)
self._metrics[hub_id]["processed_today"] += 1
logger.info(f"Order {order_id} received at {hub['name']} | load={hub['current_load']}/{hub['capacity']}")
await self.send_message(
recipient="CUSTOMER_AGENT",
message_type=MessageType.HUB_STATUS_UPDATE,
payload={
"order_id": order_id,
"hub_id": hub_id,
"hub_name": hub["name"],
"status": "arrived_at_hub",
"arrival_time": datetime.now().isoformat(),
},
)
return {"status": "processed", "record_id": record_id, "hub_id": hub_id, "current_load": hub["current_load"]}
async def _dispatch_outgoing(self, task: AgentTask) -> Dict[str, Any]:
hub_id = task.data.get("hub_id")
order_id = task.data.get("order_id")
if hub_id not in self._hubs:
return {"status": "error", "message": f"Hub {hub_id} not found"}
hub = self._hubs[hub_id]
record = None
for r in self._in_transit[hub_id]:
if r.order_id == order_id and r.status in ["processing", "waiting"]:
r.actual_exit = datetime.now()
r.status = "in_transit"
record = r
break
if record:
hub["current_load"] -= 1
logger.info(f"Order {order_id} dispatched from {hub['name']}")
return {
"status": "dispatched",
"record_id": record.record_id,
"hub_id": hub_id,
"exit_time": record.actual_exit.isoformat(),
"destination": record.destination,
}
return {"status": "error", "message": "Transit record not found"}
async def _get_hub_status(self, task: AgentTask) -> Dict[str, Any]:
hub_id = task.data.get("hub_id")
if hub_id:
if hub_id not in self._hubs:
return {"status": "error", "message": f"Hub {hub_id} not found"}
return self._format_hub_status(hub_id)
return {
"hubs": {hid: self._format_hub_status(hid) for hid in self._hubs.keys()},
"total_hubs": len(self._hubs),
}
def _format_hub_status(self, hub_id: str) -> Dict[str, Any]:
hub = self._hubs[hub_id]
metrics = self._metrics[hub_id]
utilization = (hub["current_load"] / hub["capacity"]) * 100 if hub["capacity"] > 0 else 0
return {
"hub_id": hub_id,
"name": hub["name"],
"location": hub["location"],
"capacity": hub["capacity"],
"current_load": hub["current_load"],
"utilization_percent": round(utilization, 1),
"spokes": hub["spokes"],
"connected_hubs": hub["connected_hubs"],
"processing_queue": len(self._processing_queues[hub_id]),
"in_transit": len([r for r in self._in_transit[hub_id] if r.status == "in_transit"]),
"metrics": {
"processed_today": metrics["processed_today"],
"avg_processing_time": metrics["average_processing_time"],
"efficiency": metrics["efficiency"],
},
"status": "operational" if utilization < 90 else "high_load",
}
async def _transfer_between_hubs(self, task: AgentTask) -> Dict[str, Any]:
from_hub = task.data.get("from_hub")
to_hub = task.data.get("to_hub")
order_id = task.data.get("order_id")
if from_hub not in self._hubs or to_hub not in self._hubs:
return {"status": "error", "message": "Invalid hub ID(s)"}
if to_hub not in self._hubs[from_hub]["connected_hubs"]:
return {
"status": "no_direct_route",
"message": f"No direct route from {from_hub} to {to_hub}",
"suggested_route": self._find_route(from_hub, to_hub),
}
transfer_id = f"XF-{uuid.uuid4().hex[:8].upper()}"
logger.info(f"Hub transfer {transfer_id}: {self._hubs[from_hub]['name']} -> {self._hubs[to_hub]['name']} | order={order_id}")
await self.send_message(
recipient="ROUTE_OPTIMIZER",
message_type=MessageType.AGENT_TASK,
payload={
"task_type": "plan_inter_hub_route",
"transfer_id": transfer_id,
"from_hub": from_hub,
"to_hub": to_hub,
"order_id": order_id,
},
)
return {
"status": "transfer_initiated",
"transfer_id": transfer_id,
"from_hub": from_hub,
"to_hub": to_hub,
"estimated_time": 180,
"route": [from_hub, to_hub],
}
async def _get_transit_status(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
transit_info = []
for hub_id, records in self._in_transit.items():
for record in records:
if record.order_id == order_id:
transit_info.append({
"record_id": record.record_id,
"hub_id": hub_id,
"hub_name": self._hubs[hub_id]["name"],
"status": record.status,
"entry_time": record.entry_time.isoformat(),
"expected_exit": record.expected_exit.isoformat(),
"actual_exit": record.actual_exit.isoformat() if record.actual_exit else None,
"destination": record.destination,
})
return {
"order_id": order_id,
"transit_records": transit_info,
"current_location": transit_info[-1]["hub_name"] if transit_info else "unknown",
}
async def _optimize_hub_operations(self, task: AgentTask) -> Dict[str, Any]:
hub_id = task.data.get("hub_id")
if hub_id not in self._hubs:
return {"status": "error", "message": f"Hub {hub_id} not found"}
hub = self._hubs[hub_id]
metrics = self._metrics[hub_id]
recommendations = []
utilization = (hub["current_load"] / hub["capacity"]) * 100
if utilization > 80:
recommendations.append({
"type": "capacity", "priority": "high",
"message": f"High utilization at {utilization:.0f}% - consider overflow routing",
"action": "request_additional_vehicle",
})
queue_length = len(self._processing_queues[hub_id])
if queue_length > 20:
recommendations.append({
"type": "queue", "priority": "medium",
"message": f"Long processing queue ({queue_length} items)",
"action": "increase_processing_capacity",
})
if metrics["average_processing_time"] > 30:
recommendations.append({
"type": "efficiency", "priority": "medium",
"message": "Above average processing time",
"action": "review_sorting_operations",
})
logger.info(f"Hub {hub['name']}: util={utilization:.1f}% queue={queue_length} recommendations={len(recommendations)}")
return {
"hub_id": hub_id,
"recommendations": recommendations,
"potential_savings": {
"time_minutes": len(recommendations) * 10,
"cost_percent": len(recommendations) * 2,
},
}
async def _escalate_overflow(self, task: AgentTask) -> Dict[str, Any]:
from_hub = task.data.get("from_hub")
order_id = task.data.get("order_id")
if from_hub not in self._hubs:
return {"status": "error", "message": f"Hub {from_hub} not found"}
hub = self._hubs[from_hub]
alternatives = []
for connected in hub["connected_hubs"]:
alt_hub = self._hubs[connected]
utilization = (alt_hub["current_load"] / alt_hub["capacity"]) * 100
if utilization < 70:
alternatives.append({
"hub_id": connected,
"name": alt_hub["name"],
"utilization": utilization,
})
if not alternatives:
return {"status": "no_alternatives", "message": "All connected hubs at capacity"}
best_alt = alternatives[0]
logger.warning(f"Hub overflow: routing to {best_alt['name']}")
return await self._transfer_between_hubs(AgentTask(
task_id=f"overflow_{order_id}",
agent_type="hub",
task_type="transfer_between_hubs",
data={"from_hub": from_hub, "to_hub": best_alt["hub_id"], "order_id": order_id},
))
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
def _find_route(self, from_hub: str, to_hub: str) -> List[str]:
"""BFS route between hubs."""
if from_hub == to_hub:
return [from_hub]
visited = {from_hub}
queue = [(from_hub, [from_hub])]
while queue:
current, path = queue.pop(0)
for neighbor in self._hubs[current]["connected_hubs"]:
if neighbor == to_hub:
return path + [to_hub]
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
return []
async def think(self, context: str, options: List[str] = None) -> str:
return f"[HUB_AGENT reasoning]: {context}"

337
agents/order_agent.py Normal file
View File

@@ -0,0 +1,337 @@
"""Order Agent - Handles order intake, validation, and categorization."""
import uuid
from datetime import datetime
from typing import Dict, List, Any, Optional
from core.agent import SpecializedAgent
from core.types import (
AgentTask, MessageType, Priority, OrderStatus, ZoneType
)
from core.logger import logger
from core.http_client import api_get, api_post, api_patch
from config.system_config import GO_API_BASE_URL
class OrderAgent(SpecializedAgent):
"""
Order Agent - Manages the entire order lifecycle from intake to validation.
Writes go to the Doormile Go backend (POST /api/v1/admin/crmbooking).
Reads and status updates also go through the Go API.
"""
def __init__(self):
super().__init__(
agent_id="ORDER_AGENT",
domain="order_management",
description="Handles order intake, validation, and categorization"
)
self._validation_rules = self._init_validation_rules()
self._pincode_zones = self._init_pincode_mapping()
def _init_validation_rules(self) -> Dict[str, Any]:
return {
"required_fields": ["customer_name", "customer_phone", "pickup_address", "delivery_address", "items"],
"phone_pattern": r'^\+?[1-9]\d{9,14}$',
"email_pattern": r'^[\w\.-]+@[\w\.-]+\.\w+$',
"pincode_length": 6,
"max_items_per_order": 100,
"max_weight_kg": 500,
}
def _init_pincode_mapping(self) -> Dict[str, Any]:
return {
"100": {"zone": "north", "region": "delhi_ncr", "hub_prefix": "DL"},
"101": {"zone": "north", "region": "delhi_ncr", "hub_prefix": "DL"},
"200": {"zone": "south", "region": "hyderabad", "hub_prefix": "HY"},
"201": {"zone": "south", "region": "bangalore", "hub_prefix": "BL"},
"400": {"zone": "west", "region": "mumbai", "hub_prefix": "MU"},
"401": {"zone": "west", "region": "pune", "hub_prefix": "PU"},
"500": {"zone": "central", "region": "bhopal", "hub_prefix": "BH"},
"600": {"zone": "east", "region": "kolkata", "hub_prefix": "KL"},
"700": {"zone": "northeast", "region": "guwahati", "hub_prefix": "GH"},
"800": {"zone": "northwest", "region": "jaipur", "hub_prefix": "JP"},
}
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
handlers = {
"receive_order": self._receive_order,
"validate_order": self._validate_order,
"categorize_order": self._categorize_order,
"update_status": self._update_status,
"cancel_order": self._cancel_order,
"get_order": self._get_order,
"list_orders": self._list_orders,
}
handler = handlers.get(task.task_type, self._unknown_task)
return await handler(task)
# ------------------------------------------------------------------ #
# Go API helpers (use shared session + retry from http_client) #
# ------------------------------------------------------------------ #
async def _api_post(self, path: str, payload: Dict) -> Optional[Dict]:
result = await api_post(f"{GO_API_BASE_URL}{path}", json=payload)
if result is None:
logger.error(f"Go API POST {path} returned no response")
return result
async def _api_get(self, path: str, params: Dict = None) -> Optional[Dict]:
result = await api_get(f"{GO_API_BASE_URL}{path}", params=params)
if result is None:
logger.error(f"Go API GET {path} returned no response")
return result
async def _api_patch(self, path: str, payload: Dict) -> Optional[Dict]:
result = await api_patch(f"{GO_API_BASE_URL}{path}", json=payload)
if result is None:
logger.error(f"Go API PATCH {path} returned no response")
return result
# ------------------------------------------------------------------ #
# Task handlers #
# ------------------------------------------------------------------ #
async def _receive_order(self, task: AgentTask) -> Dict[str, Any]:
order_data = task.data.get("order", {})
order_id = f"ORD-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
order_data["order_id"] = order_id
order_data["status"] = OrderStatus.RECEIVED.value
order_data["created_at"] = datetime.now().isoformat()
logger.info(f"Order Agent: Received order {order_id} | customer={order_data.get('customer_name')} items={len(order_data.get('items', []))}")
validation_result = await self._validate_order_data(order_data)
order_data["validation"] = validation_result
category = self._categorize_order_data(order_data)
order_data["category"] = category
api_result = await self._api_post("/api/v1/admin/crmbooking", order_data)
if api_result is None:
logger.warning(f"Failed to persist order {order_id} to Go backend")
else:
go_id = (api_result.get("data") or {}).get("bookingid")
if go_id:
order_id = str(go_id)
order_data["order_id"] = order_id
order_data["booking_id"] = order_id
logger.info(f"Go API booking ID: {order_id}")
await self.send_message(
recipient="JARVIS",
message_type=MessageType.ORDER_RECEIVED,
payload={
"order_id": order_id,
"booking_id": order_id,
"customer": order_data.get("customer_name"),
"priority": category.get("priority"),
"zone": category.get("zone"),
"validation_status": validation_result.get("status"),
},
correlation_id=order_id,
)
return {
"status": "received",
"order_id": order_id,
"validation": validation_result,
"category": category,
}
async def _validate_order(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
order_data = await self._api_get(f"/api/v1/admin/crmbooking/{order_id}")
if not order_data:
return {"status": "error", "message": f"Order {order_id} not found"}
validation = await self._validate_order_data(order_data)
if validation.get("status") == "valid":
await self._api_patch(
f"/api/v1/admin/crmbooking/{order_id}",
{"status": OrderStatus.VALIDATED.value, "validation": validation},
)
return validation
async def _categorize_order(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
order_data = await self._api_get(f"/api/v1/admin/crmbooking/{order_id}")
if not order_data:
return {"status": "error", "message": f"Order {order_id} not found"}
return self._categorize_order_data(order_data)
async def _update_status(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
new_status = task.data.get("status")
result = await self._api_patch(
f"/api/v1/admin/crmbooking/{order_id}",
{"status": new_status, "status_updated_at": datetime.now().isoformat()},
)
if not result:
return {"status": "error", "message": f"Failed to update order {order_id}"}
logger.info(f"Order {order_id} status -> {new_status}")
await self.send_message(
recipient="CUSTOMER_AGENT",
message_type=MessageType.ORDER_STATUS_UPDATE,
payload={"order_id": order_id, "new_status": new_status},
)
return {"status": "updated", "order_id": order_id, "new_status": new_status}
async def _cancel_order(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
reason = task.data.get("reason", "Customer requested cancellation")
result = await self._api_patch(
f"/api/v1/admin/crmbooking/{order_id}",
{
"status": OrderStatus.CANCELLED.value,
"cancellation_reason": reason,
"cancelled_at": datetime.now().isoformat(),
},
)
if not result:
return {"status": "error", "message": f"Failed to cancel order {order_id}"}
logger.info(f"Order {order_id} cancelled: {reason}")
await self.broadcast(
message_type=MessageType.ORDER_CANCELLED,
payload={"order_id": order_id, "reason": reason},
correlation_id=order_id,
)
return {"status": "cancelled", "order_id": order_id, "reason": reason}
async def _get_order(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
order_data = await self._api_get(f"/api/v1/admin/crmbooking/{order_id}")
if not order_data:
return {"status": "error", "message": f"Order {order_id} not found"}
return order_data
async def _list_orders(self, task: AgentTask) -> Dict[str, Any]:
params = {}
if task.data.get("status"):
params["status"] = task.data["status"]
if task.data.get("priority"):
params["priority"] = task.data["priority"]
result = await self._api_get("/api/v1/admin/crmbooking", params=params)
if not result:
return {"total": 0, "orders": []}
orders = result if isinstance(result, list) else result.get("data", [])
return {"total": len(orders), "orders": orders}
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
return {"status": "error", "message": f"Unknown task type: {task.task_type}"}
# ------------------------------------------------------------------ #
# Validation / categorization (local, no DB) #
# ------------------------------------------------------------------ #
async def _validate_order_data(self, order_data: Dict) -> Dict[str, Any]:
errors = []
warnings = []
for field in self._validation_rules["required_fields"]:
if field not in order_data or not order_data[field]:
errors.append(f"Missing required field: {field}")
phone = order_data.get("customer_phone", "")
if phone and not self._is_valid_phone(phone):
errors.append("Invalid phone number format")
email = order_data.get("customer_email")
if email and not self._is_valid_email(email):
warnings.append("Invalid email format")
pickup_pincode = order_data.get("pickup_address", {}).get("pincode", "")
if not self._is_valid_pincode(pickup_pincode):
errors.append("Invalid pickup pincode")
delivery_pincode = order_data.get("delivery_address", {}).get("pincode", "")
if not self._is_valid_pincode(delivery_pincode):
errors.append("Invalid delivery pincode")
items = order_data.get("items", [])
if not items:
errors.append("No items in order")
elif len(items) > self._validation_rules["max_items_per_order"]:
errors.append(f"Too many items (max {self._validation_rules['max_items_per_order']})")
total_weight = sum(item.get("weight", 0) for item in items)
if total_weight > self._validation_rules["max_weight_kg"]:
warnings.append(f"Heavy order ({total_weight}kg) - may require special handling")
return {
"status": "valid" if not errors else "invalid",
"errors": errors,
"warnings": warnings,
"total_weight_kg": total_weight,
}
def _categorize_order_data(self, order_data: Dict) -> Dict[str, Any]:
pickup_pincode = order_data.get("pickup_address", {}).get("pincode", "")[:3]
delivery_pincode = order_data.get("delivery_address", {}).get("pincode", "")[:3]
if pickup_pincode == delivery_pincode:
zone_type = ZoneType.LAST_MILE.value
else:
pickup_zone = self._pincode_zones.get(pickup_pincode, {})
delivery_zone = self._pincode_zones.get(delivery_pincode, {})
if pickup_zone.get("zone") == delivery_zone.get("zone"):
zone_type = ZoneType.HUB_TO_SPOKE.value
else:
zone_type = ZoneType.HUB_TO_HUB.value
priority = order_data.get("priority", Priority.MEDIUM.value)
total_weight = sum(item.get("weight", 0) for item in order_data.get("items", []))
if total_weight > 50 or order_data.get("special_instructions"):
priority = Priority.HIGH.value
pickup_info = self._pincode_zones.get(pickup_pincode, {"zone": "unknown", "region": "unknown"})
return {
"priority": priority,
"zone_type": zone_type,
"pickup_zone": pickup_info.get("zone"),
"delivery_zone": self._pincode_zones.get(delivery_pincode, {}).get("zone"),
"pickup_region": pickup_info.get("region"),
"estimated_hubs": self._get_nearest_hubs(pickup_pincode),
"requires_cold_chain": self._check_cold_chain_requirement(order_data),
"is_fragile": self._check_fragile_items(order_data),
}
def _get_nearest_hubs(self, pincode: str) -> List[str]:
zone = self._pincode_zones.get(pincode[:3], {})
hub_prefix = zone.get("hub_prefix", "XX")
return [f"{hub_prefix}-HUB-01", f"{hub_prefix}-HUB-02"]
def _check_cold_chain_requirement(self, order_data: Dict) -> bool:
return any(item.get("requires_cold_chain") for item in order_data.get("items", []))
def _check_fragile_items(self, order_data: Dict) -> bool:
return any(item.get("fragile") for item in order_data.get("items", []))
def _is_valid_phone(self, phone: str) -> bool:
import re
return bool(re.match(self._validation_rules["phone_pattern"], phone))
def _is_valid_email(self, email: str) -> bool:
import re
return bool(re.match(self._validation_rules["email_pattern"], email))
def _is_valid_pincode(self, pincode: str) -> bool:
return len(pincode) == self._validation_rules["pincode_length"] and pincode.isdigit()
async def think(self, context: str, options: List[str] = None) -> str:
return f"[ORDER_AGENT reasoning]: {context}"

View File

@@ -0,0 +1,465 @@
"""Route Optimizer Agent - Optimizes delivery routes based on zones and available hubs."""
import uuid
from datetime import datetime, timedelta
from time import monotonic
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass
from math import radians, cos, sin, asin, sqrt
from collections import defaultdict
from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType, ZoneType
from core.logger import logger
_CACHE_TTL_SECONDS = 600 # 10 minutes
@dataclass
class Waypoint:
location_id: str
lat: float
lng: float
address: str
type: str # pickup, delivery, hub, spoke
order_id: Optional[str] = None
time_window_start: Optional[datetime] = None
time_window_end: Optional[datetime] = None
@dataclass
class Route:
route_id: str
waypoints: List[Waypoint]
total_distance_km: float
estimated_duration_minutes: float
vehicle_id: str
zones_traversed: List[str]
fuel_cost: float
efficiency_score: float
class RouteOptimizerAgent(SpecializedAgent):
"""Route Optimizer Agent - Optimizes delivery routes based on zones, traffic, and constraints."""
def __init__(self):
super().__init__(
agent_id="ROUTE_OPTIMIZER",
domain="route_optimization",
description="Optimizes delivery routes based on zones, hubs, and constraints"
)
self._hubs = {
"DL-HUB-01": (28.6139, 77.2090),
"DL-HUB-02": (28.5355, 77.2100),
"MU-HUB-01": (19.0760, 72.8777),
"MU-HUB-02": (19.1650, 72.8500),
"BL-HUB-01": (12.9716, 77.5946),
"HY-HUB-01": (17.3850, 78.4867),
"PU-HUB-01": (18.5204, 73.8567),
"KL-HUB-01": (22.5726, 88.3639),
}
self._zones = self._init_zones()
# Cache stores (Route, created_at_monotonic) — evicted after _CACHE_TTL_SECONDS
self._route_cache: Dict[str, Tuple[Route, float]] = {}
self._traffic_patterns = self._init_traffic_patterns()
self._route_history: List[Dict] = []
def _init_zones(self) -> Dict[str, Dict]:
return {
"north_delhi": {"pincode_range": ("100", "199"), "center": (28.6139, 77.2090), "hub": "DL-HUB-01", "typical_traffic": "medium"},
"south_delhi": {"pincode_range": ("200", "299"), "center": (28.5355, 77.2100), "hub": "DL-HUB-02", "typical_traffic": "high"},
"mumbai_west": {"pincode_range": ("400", "449"), "center": (19.0760, 72.8777), "hub": "MU-HUB-01", "typical_traffic": "high"},
"mumbai_east": {"pincode_range": ("450", "499"), "center": (19.1650, 72.8500), "hub": "MU-HUB-02", "typical_traffic": "medium"},
"bangalore": {"pincode_range": ("560", "562"), "center": (12.9716, 77.5946), "hub": "BL-HUB-01", "typical_traffic": "medium"},
"hyderabad": {"pincode_range": ("500", "599"), "center": (17.3850, 78.4867), "hub": "HY-HUB-01", "typical_traffic": "medium"},
"pune": {"pincode_range": ("400", "499"), "center": (18.5204, 73.8567), "hub": "PU-HUB-01", "typical_traffic": "medium"},
"kolkata": {"pincode_range": ("600", "699"), "center": (22.5726, 88.3639), "hub": "KL-HUB-01", "typical_traffic": "low"},
}
def _init_traffic_patterns(self) -> Dict[str, Dict]:
return {
"morning": {"multiplier": 1.2, "description": "7AM-10AM rush"},
"midday": {"multiplier": 1.0, "description": "10AM-4PM normal"},
"evening": {"multiplier": 1.5, "description": "4PM-8PM rush"},
"night": {"multiplier": 0.8, "description": "8PM-7AM light"},
}
# ------------------------------------------------------------------ #
# Cache helpers with TTL #
# ------------------------------------------------------------------ #
def _cache_put(self, route_id: str, route: Route):
self._route_cache[route_id] = (route, monotonic())
def _cache_get(self, route_id: str) -> Optional[Route]:
entry = self._route_cache.get(route_id)
if entry is None:
return None
route, ts = entry
if monotonic() - ts >= _CACHE_TTL_SECONDS:
del self._route_cache[route_id]
return None
return route
async def _heartbeat(self):
"""Evict expired entries from route cache."""
now = monotonic()
expired = [rid for rid, (_, ts) in self._route_cache.items() if now - ts >= _CACHE_TTL_SECONDS]
for rid in expired:
del self._route_cache[rid]
if expired:
logger.debug(f"Route cache: evicted {len(expired)} expired entries ({len(self._route_cache)} remaining)")
# ------------------------------------------------------------------ #
# Task dispatch #
# ------------------------------------------------------------------ #
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
handlers = {
"optimize_route": self._optimize_route,
"plan_multi_stop": self._plan_multi_stop,
"plan_inter_hub_route": self._plan_inter_hub_route,
"calculate_eta": self._calculate_eta,
"avoid_zone": self._avoid_zone,
"reoptimize_route": self._reoptimize_route,
"get_zone_routes": self._get_zone_routes,
"batch_optimize": self._batch_optimize,
}
handler = handlers.get(task.task_type, self._unknown_task)
return await handler(task)
async def _optimize_route(self, task: AgentTask) -> Dict[str, Any]:
order_id = task.data.get("order_id")
pickup = task.data.get("pickup", {})
delivery = task.data.get("delivery", {})
vehicle_type = task.data.get("vehicle_type", "van")
logger.info(f"Route Optimizer: Optimizing route for order {order_id}")
pickup_coords = (pickup.get("lat", 28.6139), pickup.get("lng", 77.2090))
delivery_coords = (delivery.get("lat", 19.0760), delivery.get("lng", 72.8777))
direct_distance = self._haversine_distance(pickup_coords, delivery_coords)
optimal_path = self._find_optimal_path(pickup_coords, delivery_coords)
total_distance = self._calculate_total_distance(optimal_path)
traffic_multiplier = self._get_traffic_multiplier()
estimated_time = (total_distance / 30) * traffic_multiplier * 60
route_id = f"RT-OPT-{uuid.uuid4().hex[:8].upper()}"
waypoints = []
for i, coords in enumerate(optimal_path):
hub_id = self._find_nearest_hub(coords)
waypoints.append(Waypoint(
location_id=f"WPT-{i}",
lat=coords[0],
lng=coords[1],
address=str(self._hubs.get(hub_id, ("Unknown",))[0]) if hub_id else "Route point",
type="hub" if 0 < i < len(optimal_path) - 1 else ("pickup" if i == 0 else "delivery"),
order_id=order_id,
))
route = Route(
route_id=route_id,
waypoints=waypoints,
total_distance_km=total_distance,
estimated_duration_minutes=estimated_time,
vehicle_id=task.data.get("vehicle_id", ""),
zones_traversed=self._identify_zones(optimal_path),
fuel_cost=total_distance * 3.5,
efficiency_score=self._calculate_efficiency(total_distance, direct_distance),
)
self._cache_put(route_id, route)
logger.info(
f"Route {route_id}: {len(waypoints)} waypoints | {total_distance:.1f} km | "
f"ETA {estimated_time:.0f} min | efficiency {route.efficiency_score:.0f}%"
)
return {
"status": "optimized",
"route_id": route_id,
"waypoints": [{"lat": w.lat, "lng": w.lng, "type": w.type, "address": w.address} for w in waypoints],
"total_distance_km": total_distance,
"estimated_duration_minutes": estimated_time,
"zones_traversed": route.zones_traversed,
"fuel_cost": route.fuel_cost,
"efficiency_score": route.efficiency_score,
}
async def _plan_multi_stop(self, task: AgentTask) -> Dict[str, Any]:
stops = task.data.get("stops", [])
vehicle_id = task.data.get("vehicle_id")
logger.info(f"Route Optimizer: Planning multi-stop route with {len(stops)} stops")
waypoints = [
Waypoint(
location_id=f"STOP-{i}",
lat=stop.get("lat"),
lng=stop.get("lng"),
address=stop.get("address", ""),
type=stop.get("type", "delivery"),
order_id=stop.get("order_id"),
)
for i, stop in enumerate(stops)
]
optimized_order = self._nearest_neighbor_optimization(waypoints)
total_distance = self._calculate_route_distance(optimized_order)
estimated_time = (total_distance / 25) * 60
route_id = f"RT-MULTI-{uuid.uuid4().hex[:8].upper()}"
route = Route(
route_id=route_id,
waypoints=optimized_order,
total_distance_km=total_distance,
estimated_duration_minutes=estimated_time,
vehicle_id=vehicle_id,
zones_traversed=self._identify_zones([(w.lat, w.lng) for w in optimized_order]),
fuel_cost=total_distance * 3.5,
efficiency_score=85.0,
)
self._cache_put(route_id, route)
return {
"status": "planned",
"route_id": route_id,
"stop_order": [{"order": i + 1, "lat": w.lat, "lng": w.lng, "type": w.type} for i, w in enumerate(optimized_order)],
"total_distance_km": total_distance,
"estimated_duration_minutes": estimated_time,
}
async def _plan_inter_hub_route(self, task: AgentTask) -> Dict[str, Any]:
from_hub = task.data.get("from_hub")
to_hub = task.data.get("to_hub")
order_id = task.data.get("order_id")
logger.info(f"Route Optimizer: Inter-hub route {from_hub} -> {to_hub}")
if from_hub not in self._hubs or to_hub not in self._hubs:
return {"status": "error", "message": "Invalid hub ID(s)"}
from_coords = self._hubs[from_hub]
to_coords = self._hubs[to_hub]
direct_distance = self._haversine_distance(from_coords, to_coords)
intermediate_hub = None
if direct_distance > 500:
intermediate_hub = self._find_intermediate_hub(from_coords, to_coords)
route_coords = (
[from_coords, self._hubs[intermediate_hub], to_coords]
if intermediate_hub else
[from_coords, to_coords]
)
total_distance = self._calculate_total_distance(route_coords)
estimated_time = (total_distance / 40) * 60
route_id = f"RT-IHUB-{uuid.uuid4().hex[:8].upper()}"
return {
"status": "planned",
"route_id": route_id,
"from_hub": from_hub,
"to_hub": to_hub,
"intermediate_hub": intermediate_hub,
"waypoints": [{"hub": h, "coords": self._hubs.get(h, (0, 0))} for h in [from_hub, intermediate_hub, to_hub] if h],
"total_distance_km": total_distance,
"estimated_duration_minutes": estimated_time,
"estimated_hours": estimated_time / 60,
}
async def _calculate_eta(self, task: AgentTask) -> Dict[str, Any]:
route_id = task.data.get("route_id")
current_location = task.data.get("current_location")
cached = self._cache_get(route_id)
if cached:
return {
"route_id": route_id,
"total_eta_minutes": cached.estimated_duration_minutes,
"remaining_distance_km": cached.total_distance_km,
"current_eta": (datetime.now() + timedelta(minutes=cached.estimated_duration_minutes)).isoformat(),
}
from_coords = (current_location.get("lat", 0), current_location.get("lng", 0))
to_coords = task.data.get("destination", (0, 0))
distance = self._haversine_distance(from_coords, to_coords)
eta_minutes = (distance / 30) * self._get_traffic_multiplier() * 60
return {
"distance_km": distance,
"eta_minutes": eta_minutes,
"current_eta": (datetime.now() + timedelta(minutes=eta_minutes)).isoformat(),
}
async def _avoid_zone(self, task: AgentTask) -> Dict[str, Any]:
route_id = task.data.get("route_id")
avoid_zone = task.data.get("zone")
logger.info(f"Route Optimizer: Avoiding zone {avoid_zone}")
if self._cache_get(route_id):
return {
"status": "replanned",
"route_id": route_id,
"avoided_zone": avoid_zone,
"additional_distance_km": 5.0,
"additional_time_minutes": 15,
}
return {"status": "error", "message": "Route not found"}
async def _reoptimize_route(self, task: AgentTask) -> Dict[str, Any]:
route_id = task.data.get("route_id")
new_stops = task.data.get("new_stops", [])
logger.info(f"Route Optimizer: Reoptimizing route {route_id}")
route = self._cache_get(route_id)
if route:
for stop in new_stops:
route.waypoints.append(Waypoint(
location_id=f"NEW-{len(route.waypoints)}",
lat=stop.get("lat"),
lng=stop.get("lng"),
address=stop.get("address", ""),
type="add_delivery",
order_id=stop.get("order_id"),
))
coords = [(w.lat, w.lng) for w in route.waypoints]
route.total_distance_km = self._calculate_total_distance(coords)
route.estimated_duration_minutes = (route.total_distance_km / 25) * 60
self._cache_put(route_id, route)
return {
"status": "reoptimized",
"route_id": route_id,
"new_distance_km": route.total_distance_km,
"new_eta_minutes": route.estimated_duration_minutes,
}
return {"status": "error", "message": "Route not found"}
async def _get_zone_routes(self, task: AgentTask) -> Dict[str, Any]:
zone = task.data.get("zone")
now = monotonic()
zone_routes = []
for route_id, (route, ts) in list(self._route_cache.items()):
if now - ts >= _CACHE_TTL_SECONDS:
continue
if zone in route.zones_traversed:
zone_routes.append({
"route_id": route.route_id,
"distance_km": route.total_distance_km,
"duration_minutes": route.estimated_duration_minutes,
})
return {"zone": zone, "total_routes": len(zone_routes), "routes": zone_routes}
async def _batch_optimize(self, task: AgentTask) -> Dict[str, Any]:
orders = task.data.get("orders", [])
logger.info(f"Route Optimizer: Batch optimizing {len(orders)} orders")
zone_groups: Dict[str, list] = defaultdict(list)
for order in orders:
zone = self._identify_zone_from_coords((order.get("lat", 0), order.get("lng", 0)))
zone_groups[zone].append(order)
results = [await self._optimize_zone_routes(zone, zone_orders) for zone, zone_orders in zone_groups.items()]
return {
"status": "batch_optimized",
"zones_optimized": len(results),
"total_orders": len(orders),
"total_distance_km": sum(r["total_distance_km"] for r in results),
"total_time_minutes": sum(r["estimated_time_minutes"] for r in results),
"zone_results": results,
}
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
# ------------------------------------------------------------------ #
# Geometry helpers #
# ------------------------------------------------------------------ #
def _haversine_distance(self, coord1: Tuple[float, float], coord2: Tuple[float, float]) -> float:
lat1, lon1 = coord1
lat2, lon2 = coord2
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
return 2 * asin(sqrt(a)) * 6371
def _find_optimal_path(self, start: Tuple[float, float], end: Tuple[float, float]) -> List[Tuple[float, float]]:
start_hub = self._find_nearest_hub(start)
end_hub = self._find_nearest_hub(end)
if start_hub != end_hub:
return [start, self._hubs[start_hub], self._hubs[end_hub], end]
return [start, end]
def _find_nearest_hub(self, coords: Tuple[float, float]) -> Optional[str]:
return min(self._hubs.keys(), key=lambda h: self._haversine_distance(coords, self._hubs[h]), default=None)
def _find_intermediate_hub(self, start: Tuple[float, float], end: Tuple[float, float]) -> Optional[str]:
mid = ((start[0] + end[0]) / 2, (start[1] + end[1]) / 2)
return self._find_nearest_hub(mid)
def _calculate_total_distance(self, coords: List[Tuple[float, float]]) -> float:
return sum(self._haversine_distance(coords[i], coords[i + 1]) for i in range(len(coords) - 1))
def _calculate_route_distance(self, waypoints: List[Waypoint]) -> float:
return self._calculate_total_distance([(w.lat, w.lng) for w in waypoints])
def _get_traffic_multiplier(self) -> float:
hour = datetime.now().hour
if 7 <= hour < 10:
return self._traffic_patterns["morning"]["multiplier"]
if 10 <= hour < 16:
return self._traffic_patterns["midday"]["multiplier"]
if 16 <= hour < 20:
return self._traffic_patterns["evening"]["multiplier"]
return self._traffic_patterns["night"]["multiplier"]
def _identify_zones(self, coords: List[Tuple[float, float]]) -> List[str]:
return list({self._identify_zone_from_coords(c) for c in coords if self._identify_zone_from_coords(c)})
def _identify_zone_from_coords(self, coords: Tuple[float, float]) -> str:
return min(self._zones.keys(), key=lambda z: self._haversine_distance(coords, self._zones[z]["center"]), default="unknown")
def _calculate_efficiency(self, actual_distance: float, direct_distance: float) -> float:
if direct_distance == 0:
return 100.0
return min(100.0, (direct_distance / actual_distance) * 100)
def _nearest_neighbor_optimization(self, waypoints: List[Waypoint]) -> List[Waypoint]:
if not waypoints:
return []
unvisited = waypoints[1:]
ordered = [waypoints[0]]
while unvisited:
current = ordered[-1]
nearest = min(unvisited, key=lambda w: self._haversine_distance((current.lat, current.lng), (w.lat, w.lng)))
ordered.append(nearest)
unvisited.remove(nearest)
return ordered
async def _optimize_zone_routes(self, zone: str, orders: List[Dict]) -> Dict[str, Any]:
total_distance = 0.0
total_time = 0.0
for i in range(0, len(orders), 5):
batch = orders[i:i + 5]
coords = [(o.get("lat", 0), o.get("lng", 0)) for o in batch]
dist = self._calculate_total_distance(coords)
total_distance += dist
total_time += (dist / 25) * 60
return {"zone": zone, "orders_in_zone": len(orders), "total_distance_km": total_distance, "estimated_time_minutes": total_time}
async def think(self, context: str, options: List[str] = None) -> str:
return f"[ROUTE_OPTIMIZER reasoning]: {context}"