route optimizer agent with dailygrubs ai assign
This commit is contained in:
@@ -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) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
Reference in New Issue
Block a user