route optimizer agent with dailygrubs ai assign

This commit is contained in:
2026-09-01 13:48:58 +05:30
parent aa4d4c6549
commit 90cdb57a38
28 changed files with 3281 additions and 135 deletions

View File

@@ -1,7 +1,8 @@
"""Dispatch Agent — watches NATS assignment events and escalates coverage gaps."""
import asyncio
import json
from datetime import datetime
import os
from datetime import datetime, timezone
from typing import Dict, List, Any, Optional
import nats
@@ -11,18 +12,28 @@ import redis.asyncio as aioredis
from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType
from core.logger import logger
from core.llm import decide_assignment_failure, build_assignment_failure_context
from config.system_config import (
NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD,
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD,
)
# Notifying a customer is outward-facing, so it is gated: without autonomy the
# agent proposes it as an internal ops alert instead of messaging the customer.
DISPATCH_AGENT_AUTONOMOUS = os.getenv("DISPATCH_AGENT_AUTONOMOUS", "false").lower() == "true"
# The Go backend owns the JetStream stream carrying booking.* events; this agent
# is a pure consumer and must not create it. Leave empty to auto-discover the
# stream by subject, or pin it to a stream name if discovery isn't desired.
DISPATCH_STREAM = os.getenv("DISPATCH_STREAM", "")
class DispatchAgent(SpecializedAgent):
"""
Watches NATS ASSIGNMENTS stream for booking.assigned and
booking.assignment_failed events published by the Go backend
after routemate AI or fallback assignment. Does not trigger
assignment itself.
Consumes booking.assigned and booking.assignment_failed events published by
the Go backend (after routemate AI or fallback assignment) by binding to the
backend-owned JetStream stream that carries them. It is a pure consumer — it
does not create the stream, and does not trigger assignment itself.
"""
def __init__(self):
@@ -71,34 +82,46 @@ class DispatchAgent(SpecializedAgent):
)
self._nats_js = self._nats_nc.jetstream()
try:
await self._nats_js.add_stream(
name="ASSIGNMENTS",
subjects=["booking.>"],
)
logger.info("NATS stream 'ASSIGNMENTS' created")
except nats.js.errors.BadRequestError:
pass # stream already exists
sub_assigned = await self._nats_js.subscribe(
"booking.assigned",
durable="dispatch-booking-assigned",
cb=self._on_nats_booking_assigned,
# Bind consumers to the *existing* backend-owned stream. Do NOT create
# a stream here: booking.* is published by the Go backend, so creating
# a second stream over the same subjects raises an overlap error that
# (previously swallowed) left the consumer unbound. Mirrors the
# explicit stream= binding ExceptionAgent uses for the TRACKING stream.
sub_assigned = await self._bind_consumer(
"booking.assigned", "dispatch-booking-assigned",
self._on_nats_booking_assigned,
)
sub_failed = await self._nats_js.subscribe(
"booking.assignment_failed",
durable="dispatch-booking-assignment-failed",
cb=self._on_nats_booking_assignment_failed,
)
self._nats_subs.extend([sub_assigned, sub_failed])
logger.info(
"DispatchAgent subscribed to booking.assigned "
"and booking.assignment_failed"
sub_failed = await self._bind_consumer(
"booking.assignment_failed", "dispatch-booking-assignment-failed",
self._on_nats_booking_assignment_failed,
)
self._nats_subs.extend(s for s in (sub_assigned, sub_failed) if s is not None)
if self._nats_subs:
logger.info(f"DispatchAgent bound {len(self._nats_subs)} booking-event consumer(s)")
except Exception as e:
logger.error(f"DispatchAgent NATS connect error: {e}")
async def _bind_consumer(self, subject, durable, cb):
"""Bind a durable push consumer to whichever existing stream carries
`subject`. Returns the subscription, or None (with a clear, actionable
log) if no stream carries it — never a swallowed or opaque error."""
try:
stream = DISPATCH_STREAM or await self._nats_js.find_stream_name_by_subject(subject)
sub = await self._nats_js.subscribe(subject, durable=durable, stream=stream, cb=cb)
logger.info(f"DispatchAgent bound '{subject}' on stream '{stream}' (durable={durable})")
return sub
except nats.js.errors.NotFoundError:
logger.error(
f"DispatchAgent: no JetStream stream carries '{subject}'. The publisher "
f"(Go backend) must create a stream covering it, or set DISPATCH_STREAM to "
f"the stream name. Not subscribing to '{subject}'."
)
return None
except Exception as e:
logger.error(f"DispatchAgent failed to bind '{subject}': {e}")
return None
# ------------------------------------------------------------------ #
# Redis GEO lookup #
# ------------------------------------------------------------------ #
@@ -177,9 +200,10 @@ class DispatchAgent(SpecializedAgent):
booking.assignment_failed — Go publishes this when decide-assignment
returns escalate=true or both AI and fallback find nobody.
Widens the GEORADIUS search to gauge coverage depth, tracks daily
failures per zone in Redis, and escalates to CUSTOMER_AGENT as an
ops_alert when a zone hits 3 failures in a day.
Gathers coverage context (nearest-rider sweep + daily failure counter),
then asks Claude to choose: monitor / notify_customer / ops_alert /
escalate. Customer notification is gated behind autonomy. Falls back to
the previous count>=3 heuristic if the LLM is unavailable.
"""
try:
data = json.loads(msg.data.decode())
@@ -188,64 +212,135 @@ class DispatchAgent(SpecializedAgent):
lat = data.get("lat")
lon = data.get("lon")
logger.warning(
f"[DISPATCH] booking.assignment_failed — "
f"booking={booking_id} zone={zone_id}"
logger.warning(f"[DISPATCH] booking.assignment_failed — booking={booking_id} zone={zone_id}")
facts, count = await self._gather_assignment_facts(zone_id, lat, lon)
logger.info(f"[DISPATCH] booking={booking_id} context facts: {facts}")
decision = await decide_assignment_failure(build_assignment_failure_context(facts))
if decision is None:
logger.warning(f"No LLM decision for booking {booking_id}; falling back to count>=3 heuristic")
if count >= 3:
await self._ops_alert(
zone_id, booking_id,
f"Zone {zone_id} has had {count} failed assignments today — recommend onboarding milers here.",
)
else:
logger.info(f"[DISPATCH] zone {zone_id} failure count {count}/3 — transient, no escalation")
return
logger.info(
f"[DISPATCH] booking={booking_id} zone={zone_id} decision={decision.action} "
f"confidence={decision.confidence:.2f} reasoning={decision.reasoning!r}"
)
# Wider sweeps to gauge how far the nearest miler actually is.
has_coords = lat is not None and lon is not None
found_20km = await self._find_zone(lat, lon, radius_km=20) if has_coords else None
found_30km = await self._find_zone(lat, lon, radius_km=30) if has_coords else None
if decision.action == "monitor":
pass # transient — the backend will retry
if found_20km:
logger.info(
f"[DISPATCH] Nearest miler within 20 km: {found_20km['miler_id']}"
)
elif found_30km:
logger.info(
f"[DISPATCH] Nearest miler within 30 km: {found_30km['miler_id']}"
)
else:
logger.warning(
f"[DISPATCH] No miler found within 30 km of zone {zone_id}"
)
elif decision.action == "notify_customer":
if DISPATCH_AGENT_AUTONOMOUS:
await self._notify_customer_delay(booking_id)
else:
# Outward-facing action not authorised — raise an internal proposal instead.
await self._ops_alert(
zone_id, booking_id,
f"[proposed: notify_customer, conf={decision.confidence:.2f}] {decision.reasoning}",
)
# Increment daily failure counter (48 h TTL covers day rollover).
today = datetime.utcnow().strftime("%Y-%m-%d")
counter_key = f"failed_assignment:{zone_id}:{today}"
count = await self._redis.incr(counter_key)
await self._redis.expire(counter_key, 172800)
elif decision.action == "ops_alert":
await self._ops_alert(zone_id, booking_id, decision.reasoning)
if count >= 3:
logger.warning(
f"[DISPATCH] Coverage gap detected: zone {zone_id} has "
f"{count} failed assignments today — escalating"
)
await self.send_message(
recipient="CUSTOMER_AGENT",
message_type=MessageType.AGENT_TASK,
payload={
"task_type": "ops_alert",
"zone_id": zone_id,
"reasoning": (
f"Zone {zone_id} has had {count} failed assignments today "
f"— recommend onboarding milers here."
),
},
correlation_id=str(booking_id),
)
else:
logger.info(
f"[DISPATCH] zone {zone_id} failure count {count}/3 — "
f"transient, no escalation"
)
else: # "escalate"
await self._escalate_dispatch(zone_id, booking_id, decision)
except Exception as e:
logger.error(f"DispatchAgent booking.assignment_failed handler error: {e}")
finally:
await msg.ack()
# ------------------------------------------------------------------ #
# Context gathering + actions #
# ------------------------------------------------------------------ #
async def _gather_assignment_facts(self, zone_id, lat, lon):
"""Read-only coverage context plus the daily failure counter for this
zone. Returns (facts, failures_today). Best-effort — any failure just
yields fewer facts, never raises."""
facts: Dict[str, Any] = {"zone_id": zone_id}
has_coords = lat is not None and lon is not None
facts["has_coordinates"] = has_coords
nearest_km = None
if has_coords:
for radius in (10, 20, 30):
try:
if await self._find_zone(lat, lon, radius_km=radius):
nearest_km = radius
break
except Exception as e:
logger.warning(f"assignment facts: GEORADIUS {radius}km failed: {e}")
break
if not has_coords:
facts["nearest_miler_within_km"] = "unknown (no coordinates)"
elif nearest_km is None:
facts["nearest_miler_within_km"] = "none within 30km"
else:
facts["nearest_miler_within_km"] = nearest_km
count = 0
try:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
key = f"failed_assignment:{zone_id}:{today}"
count = await self._redis.incr(key)
await self._redis.expire(key, 172800) # 48h covers day rollover
except Exception as e:
logger.warning(f"assignment facts: failure counter failed for zone {zone_id}: {e}")
facts["failures_today"] = count
return facts, count
async def _ops_alert(self, zone_id, booking_id, reasoning):
await self.send_message(
recipient="CUSTOMER_AGENT",
message_type=MessageType.AGENT_TASK,
payload={"task_type": "ops_alert", "zone_id": zone_id, "reasoning": reasoning},
correlation_id=str(booking_id),
)
async def _notify_customer_delay(self, booking_id):
await self.send_message(
recipient="CUSTOMER_AGENT",
message_type=MessageType.AGENT_TASK,
payload={
"task_type": "customer_notify",
"booking_id": booking_id,
"message": "We're finding the right rider for your delivery — thanks for your patience.",
},
correlation_id=str(booking_id),
)
async def _escalate_dispatch(self, zone_id, booking_id, decision):
logger.warning(
f"[DISPATCH] Escalating booking {booking_id} (zone {zone_id}) to a human "
f"(proposed={decision.action}, confidence={decision.confidence:.2f}): {decision.reasoning}"
)
await self.send_message(
recipient="JARVIS",
message_type=MessageType.AGENT_TASK,
payload={
"task_type": "human_review",
"reason": "assignment_failed",
"zone_id": zone_id,
"booking_id": booking_id,
"proposed_action": decision.action,
"confidence": decision.confidence,
"reasoning": decision.reasoning,
},
correlation_id=str(booking_id),
)
# ------------------------------------------------------------------ #
# Task handler (no inbound task types remain) #
# ------------------------------------------------------------------ #