implemenation on the ai agents and registry

This commit is contained in:
2026-09-30 14:56:08 +05:30
parent aa3bb35733
commit 5a7d32cc04
15 changed files with 803 additions and 72 deletions

View File

@@ -11,6 +11,7 @@ from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType, OrderStatus from core.types import AgentTask, MessageType, OrderStatus
from core.logger import logger from core.logger import logger
from core.http_client import api_post, api_get from core.http_client import api_post, api_get
from core.registry import registry
from config.system_config import GO_API_BASE_URL, INTERNAL_API_KEY from config.system_config import GO_API_BASE_URL, INTERNAL_API_KEY
@@ -64,6 +65,32 @@ class CustomerAgent(SpecializedAgent):
self._notifications: Dict[str, List[Notification]] = {} self._notifications: Dict[str, List[Notification]] = {}
self._templates = self._init_templates() self._templates = self._init_templates()
self._preferences: Dict[str, Dict] = {} self._preferences: Dict[str, Dict] = {}
self._received_events: List[Dict[str, Any]] = []
# Event notifications other agents send without a task_type. They used to
# fall through to the base handle_message and be dropped at DEBUG level.
# They come from ExceptionAgent's generic exception tasks, which run over
# in-memory, simulated records (their order ids are not real bookings), so
# they are RECORDED here and logged — deliberately not turned into a real
# customer message through the backend's /internal/notify.
_RECORDED_EVENTS = {MessageType.ORDER_CANCELLED, MessageType.NOTIFICATION_SENT}
async def handle_message(self, message) -> None:
if message.message_type not in self._RECORDED_EVENTS:
await super().handle_message(message)
return
payload = message.payload if isinstance(message.payload, dict) else {}
event = {
"type": message.message_type.value,
"from": message.sender,
"order_id": payload.get("order_id"),
"received_at": datetime.now().isoformat(),
}
self._received_events = (self._received_events + [event])[-200:]
logger.info(
f"Customer Agent: recorded {event['type']} from {event['from']} for order {event['order_id']} "
"(simulated exception flow; no customer message sent)"
)
def _init_templates(self) -> Dict[str, Dict]: def _init_templates(self) -> Dict[str, Dict]:
return { return {
@@ -186,6 +213,9 @@ class CustomerAgent(SpecializedAgent):
return {"status": "sent", "order_id": order_id, "notifications": notifications_sent} return {"status": "sent", "order_id": order_id, "notifications": notifications_sent}
async def _send_notification(self, task: AgentTask) -> Dict[str, Any]: async def _send_notification(self, task: AgentTask) -> Dict[str, Any]:
if not registry.skill_enabled("customer_notifications"):
logger.info("Customer Agent: skill customer_notifications is disabled in the agent registry; not sending")
return {"status": "skipped", "reason": "customer_notifications disabled in the agent registry"}
order_id = task.data.get("order_id") order_id = task.data.get("order_id")
notification_type = task.data.get("notification_type") notification_type = task.data.get("notification_type")
template_vars = task.data.get("template_vars", {}) template_vars = task.data.get("template_vars", {})

View File

@@ -12,7 +12,9 @@ import redis.asyncio as aioredis
from core.agent import SpecializedAgent from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType from core.types import AgentTask, MessageType
from core.logger import logger from core.logger import logger
from core.llm import decide_assignment_failure, build_assignment_failure_context from core.llm import decide_assignment_failure, build_assignment_failure_context, LLM_MODEL
from core.registry import registry
from core.decisions import record_decision
from config.system_config import ( from config.system_config import (
NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD, NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD,
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_HOST, REDIS_PORT, REDIS_PASSWORD,
@@ -264,18 +266,12 @@ class DispatchAgent(SpecializedAgent):
f"miler={miler_id} hub={hub_id} confidence={confidence} " f"miler={miler_id} hub={hub_id} confidence={confidence} "
f"reasoning={reasoning!r}" f"reasoning={reasoning!r}"
) )
# No longer forwarded to HUB_AGENT as `prepare_receiving`. HUB_AGENT
await self.send_message( # is a simulation over eight fictional hubs keyed "XX-HUB-01"; handed a
recipient="HUB_AGENT", # real backend hub id and booking id it answered "Hub not found" for
message_type=MessageType.AGENT_TASK, # every assignment, so the hand-off did nothing but log noise. Restore
payload={ # it only once HUB_AGENT reads real hubs (agent registry status:
"task_type": "prepare_receiving", # simulation).
"booking_id": booking_id,
"miler_id": miler_id,
"hub_id": hub_id,
},
correlation_id=str(booking_id),
)
except Exception as e: except Exception as e:
logger.error(f"DispatchAgent booking.assigned handler error: {e}") logger.error(f"DispatchAgent booking.assigned handler error: {e}")
@@ -305,17 +301,25 @@ class DispatchAgent(SpecializedAgent):
f"reason={data.get('reason')!r}" f"reason={data.get('reason')!r}"
) )
if not registry.skill_enabled("assignment_failure_triage"):
logger.info(
f"[DISPATCH] booking={booking_id}: skill assignment_failure_triage is disabled "
"in the agent registry; not triaging"
)
return
facts, count = await self._gather_assignment_facts(zone_id, lat, lon) facts, count = await self._gather_assignment_facts(zone_id, lat, lon)
realert_every = int(registry.threshold("assignment_failure_triage", "realertEvery", DISPATCH_REALERT_EVERY))
logger.info(f"[DISPATCH] booking={booking_id} context facts: {facts}") logger.info(f"[DISPATCH] booking={booking_id} context facts: {facts}")
# Rate limit: once this zone has been alerted today, don't re-decide # Rate limit: once this zone has been alerted today, don't re-decide
# (and don't pay for an LLM call) on every further failure. # (and don't pay for an LLM call) on every further failure.
if facts.get("alert_already_sent_today"): if facts.get("alert_already_sent_today"):
if DISPATCH_REALERT_EVERY and count % DISPATCH_REALERT_EVERY == 0: if realert_every and count % realert_every == 0:
await self._ops_alert( await self._ops_alert(
zone_id, booking_id, zone_id, booking_id,
f"Zone {zone_id}: {count} failed assignments today and still climbing " f"Zone {zone_id}: {count} failed assignments today and still climbing "
f"(re-alert every {DISPATCH_REALERT_EVERY}; earlier alert already raised).", f"(re-alert every {realert_every}; earlier alert already raised).",
severity="high", severity="high",
) )
else: else:
@@ -325,7 +329,11 @@ class DispatchAgent(SpecializedAgent):
) )
return return
decision = await decide_assignment_failure(build_assignment_failure_context(facts)) model = registry.model("DISPATCH_AGENT")
decision = await decide_assignment_failure(build_assignment_failure_context(facts), model)
if decision is not None:
record_decision("assignment_failure", booking_id, facts, decision.action, decision.confidence,
decision.reasoning, model or LLM_MODEL)
if decision is None: if decision is None:
logger.warning(f"No LLM decision for booking {booking_id}; falling back to count>=3 heuristic") logger.warning(f"No LLM decision for booking {booking_id}; falling back to count>=3 heuristic")
@@ -347,7 +355,7 @@ class DispatchAgent(SpecializedAgent):
pass # transient — the backend will retry pass # transient — the backend will retry
elif decision.action == "notify_customer": elif decision.action == "notify_customer":
if DISPATCH_AGENT_AUTONOMOUS: if registry.autonomous("DISPATCH_AGENT", DISPATCH_AGENT_AUTONOMOUS):
await self._notify_customer_delay(booking_id) await self._notify_customer_delay(booking_id)
else: else:
# Outward-facing action not authorised — raise an internal proposal instead. # Outward-facing action not authorised — raise an internal proposal instead.

View File

@@ -26,7 +26,9 @@ from core.agent import SpecializedAgent
from core.types import AgentTask, MessageType from core.types import AgentTask, MessageType
from core.logger import logger from core.logger import logger
from core.http_client import api_post from core.http_client import api_post
from core.llm import decide_stall_response, build_stall_context, StallDecision from core.llm import decide_stall_response, build_stall_context, StallDecision, LLM_MODEL
from core.registry import registry
from core.decisions import record_decision
from config.system_config import ( from config.system_config import (
GO_API_BASE_URL, INTERNAL_API_KEY, GO_API_BASE_URL, INTERNAL_API_KEY,
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD,
@@ -34,7 +36,12 @@ from config.system_config import (
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_HOST, REDIS_PORT, REDIS_PASSWORD,
) )
STALL_MINUTES = 10 STALL_MINUTES = 10 # default; the agent registry's stall_response.stallMinutes overrides it
def stall_minutes() -> float:
"""Minutes without movement before a rider counts as stalled (registry, else 10)."""
return registry.threshold("stall_response", "stallMinutes", STALL_MINUTES)
ACTIVE_STATUSES = ["Miler_Assigned", "Pickup_Scheduled"] ACTIVE_STATUSES = ["Miler_Assigned", "Pickup_Scheduled"]
TRACKING_STREAM = "TRACKING" TRACKING_STREAM = "TRACKING"
@@ -260,7 +267,7 @@ class ExceptionAgent(SpecializedAgent):
unchanged_since = _parse_ts(prev.get("position_unchanged_since")) or _utcnow() unchanged_since = _parse_ts(prev.get("position_unchanged_since")) or _utcnow()
minutes_stalled = (_utcnow() - unchanged_since).total_seconds() / 60 minutes_stalled = (_utcnow() - unchanged_since).total_seconds() / 60
if minutes_stalled >= STALL_MINUTES: if minutes_stalled >= stall_minutes():
booking = await self._get_active_booking(miler_id) booking = await self._get_active_booking(miler_id)
if booking: if booking:
await self._publish_stall(miler_id, booking["booking_id"], minutes_stalled) await self._publish_stall(miler_id, booking["booking_id"], minutes_stalled)
@@ -311,7 +318,7 @@ class ExceptionAgent(SpecializedAgent):
continue continue
minutes_stale = (now - updated_at).total_seconds() / 60 minutes_stale = (now - updated_at).total_seconds() / 60
if minutes_stale >= STALL_MINUTES: if minutes_stale >= stall_minutes():
logger.warning(f"StallDetector: miler {miler_id} stale {minutes_stale:.1f} min (booking {booking_id})") 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) await self._publish_stall(miler_id, booking_id, minutes_stale)
@@ -385,15 +392,24 @@ class ExceptionAgent(SpecializedAgent):
Claude chooses one of wait / notify_only / reassign / escalate. The only Claude chooses one of wait / notify_only / reassign / escalate. The only
irreversible, customer-visible action (reassign) is gated behind an irreversible, customer-visible action (reassign) is gated behind an
autonomy flag and a confidence threshold; otherwise it is escalated to a autonomy flag and a confidence threshold; otherwise it is escalated to a
human via JARVIS. If the LLM is unavailable, we fall back to the previous human via JARVIS. If the LLM is unavailable: an autonomous agent falls
deterministic behaviour (reassign + notify) so detection never silently back to reassign + notify so detection never silently stops acting; a
stops acting. non-autonomous one escalates to a human instead (it used to reassign
regardless, which made "autonomy off" untrue during an LLM outage).
Settings come from the agent registry (core/registry.py), falling back
to env: skill `stall_response` on/off, its `stallMinutes` and
`reassignConfidence`, and this agent's autonomy and model.
""" """
try: try:
data = json.loads(msg.data.decode()) data = json.loads(msg.data.decode())
except Exception: except Exception:
return return
if not registry.skill_enabled("stall_response"):
logger.info("Stall received but skill stall_response is disabled in the agent registry; not acting")
return
miler_id = data.get("miler_id", "") miler_id = data.get("miler_id", "")
booking_id = data.get("booking_id", "") booking_id = data.get("booking_id", "")
minutes_stalled = data.get("minutes_stalled", 0) minutes_stalled = data.get("minutes_stalled", 0)
@@ -409,21 +425,37 @@ class ExceptionAgent(SpecializedAgent):
logger.info(f"[EXCEPTION] booking={booking_id} context facts: {facts}") logger.info(f"[EXCEPTION] booking={booking_id} context facts: {facts}")
context = build_stall_context( context = build_stall_context(
minutes_stalled, stall_threshold_min=STALL_MINUTES, now=_utcnow(), facts=facts, minutes_stalled, stall_threshold_min=stall_minutes(), now=_utcnow(), facts=facts,
) )
decision = await decide_stall_response(context) model = registry.model("EXCEPTION_AGENT")
decision = await decide_stall_response(context, model)
autonomous = registry.autonomous("EXCEPTION_AGENT", AUTONOMOUS_REASSIGN)
if decision is None: if decision is None:
logger.warning(f"No LLM decision for booking {booking_id}; falling back to reassign + notify") if autonomous:
await self._reassign(booking_id, "miler_stalled") logger.warning(f"No LLM decision for booking {booking_id}; autonomous — falling back to reassign + notify")
await self._notify_customer(booking_id, "We detected a delay, finding you a new miler") await self._reassign(booking_id, "miler_stalled")
self._record_stall_exception( await self._notify_customer(booking_id, "We detected a delay, finding you a new miler")
miler_id, booking_id, minutes_stalled, self._record_stall_exception(
resolution="Fallback (LLM unavailable): reassignment triggered + customer notified", miler_id, booking_id, minutes_stalled,
actions=["reassign", "notify_customer"], resolution="Fallback (LLM unavailable): reassignment triggered + customer notified",
) actions=["reassign", "notify_customer"],
)
else:
logger.warning(f"No LLM decision for booking {booking_id}; not autonomous — escalating to a human")
await self._escalate_to_human(miler_id, booking_id, minutes_stalled, StallDecision(
action="escalate", reasoning="LLM unavailable; autonomy is off, so a human decides.", confidence=0.0,
))
self._record_stall_exception(
miler_id, booking_id, minutes_stalled,
resolution="Fallback (LLM unavailable, autonomy off): escalated to a human",
actions=["escalate"],
)
return return
record_decision("stall_response", booking_id, facts, decision.action, decision.confidence,
decision.reasoning, model or LLM_MODEL)
logger.info( logger.info(
f"[EXCEPTION] booking={booking_id} decision={decision.action} " f"[EXCEPTION] booking={booking_id} decision={decision.action} "
f"confidence={decision.confidence:.2f} reasoning={decision.reasoning!r}" f"confidence={decision.confidence:.2f} reasoning={decision.reasoning!r}"
@@ -441,7 +473,8 @@ class ExceptionAgent(SpecializedAgent):
actions.append("notify_customer") actions.append("notify_customer")
elif decision.action == "reassign": elif decision.action == "reassign":
if AUTONOMOUS_REASSIGN and decision.confidence >= REASSIGN_MIN_CONFIDENCE: min_confidence = registry.threshold("stall_response", "reassignConfidence", REASSIGN_MIN_CONFIDENCE)
if autonomous and decision.confidence >= min_confidence:
await self._reassign(booking_id, "miler_stalled") await self._reassign(booking_id, "miler_stalled")
await self._notify_customer(booking_id, "We detected a delay, finding you a new miler") await self._notify_customer(booking_id, "We detected a delay, finding you a new miler")
actions += ["reassign", "notify_customer"] actions += ["reassign", "notify_customer"]

View File

@@ -36,6 +36,7 @@ from core.agent import SpecializedAgent
from core.types import AgentTask from core.types import AgentTask
from core.logger import logger from core.logger import logger
from core.http_client import api_get, api_post from core.http_client import api_get, api_post
from core.registry import registry
from config.system_config import ( from config.system_config import (
NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD, NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD,
GO_API_BASE_URL, INTERNAL_API_KEY, ROUTE_OPTIMIZER_URL, GO_API_BASE_URL, INTERNAL_API_KEY, ROUTE_OPTIMIZER_URL,
@@ -162,7 +163,9 @@ class ExpressDispatchAgent(SpecializedAgent):
logger.info( logger.info(
f"[EXPRESS] dispatch received — tenant={tenant_id} bookings={len(booking_ids)}" f"[EXPRESS] dispatch received — tenant={tenant_id} bookings={len(booking_ids)}"
) )
if tenant_id and booking_ids: if not registry.skill_enabled("express_batch_dispatch"):
logger.info("[EXPRESS] skill express_batch_dispatch is disabled in the agent registry; batch left for the console")
elif tenant_id and booking_ids:
await self._handle_batch(tenant_id, booking_ids) await self._handle_batch(tenant_id, booking_ids)
except Exception as e: except Exception as e:
logger.error(f"ExpressDispatchAgent batch handler error: {e}") logger.error(f"ExpressDispatchAgent batch handler error: {e}")
@@ -212,10 +215,10 @@ class ExpressDispatchAgent(SpecializedAgent):
return return
# 3. Write back (or, in observation mode, only log the plan). # 3. Write back (or, in observation mode, only log the plan).
if not EXPRESS_AGENT_AUTONOMOUS: if not registry.autonomous("EXPRESS_DISPATCH_AGENT", EXPRESS_AGENT_AUTONOMOUS):
logger.info( logger.info(
f"[EXPRESS] tenant={tenant_id} OBSERVE-ONLY plan " f"[EXPRESS] tenant={tenant_id} OBSERVE-ONLY plan "
f"(EXPRESS_AGENT_AUTONOMOUS=false): {json.dumps(assignments)}" f"(autonomy off): {json.dumps(assignments)}"
) )
return return
@@ -247,6 +250,10 @@ class ExpressDispatchAgent(SpecializedAgent):
part (ordering); this only decides who.""" part (ordering); this only decides who."""
load: Dict[int, int] = {r["miler_user_id"]: 0 for r in riders} load: Dict[int, int] = {r["miler_user_id"]: 0 for r in riders}
rider_stops: Dict[int, List[Dict]] = {} rider_stops: Dict[int, List[Dict]] = {}
# Tuned in the agent registry (skill express_batch_dispatch), else env.
max_per_rider = int(registry.threshold("express_batch_dispatch", "maxPerRider", MAX_PER_RIDER))
max_radius_km = registry.threshold("express_batch_dispatch", "maxRadiusKm", MAX_RADIUS_KM)
load_penalty_km = registry.threshold("express_batch_dispatch", "loadPenaltyKm", LOAD_PENALTY_KM)
# Assign larger-pickup-cluster bookings first is unnecessary; simple # Assign larger-pickup-cluster bookings first is unnecessary; simple
# stable order keeps it predictable and testable. # stable order keeps it predictable and testable.
@@ -255,13 +262,13 @@ class ExpressDispatchAgent(SpecializedAgent):
best_rider = None best_rider = None
best_score = None best_score = None
for r in riders: for r in riders:
if load[r["miler_user_id"]] >= MAX_PER_RIDER: if load[r["miler_user_id"]] >= max_per_rider:
continue continue
rlat, rlon = _rider_location(r, plat, plon) rlat, rlon = _rider_location(r, plat, plon)
dist = _haversine_km(rlat, rlon, plat, plon) dist = _haversine_km(rlat, rlon, plat, plon)
if dist > MAX_RADIUS_KM: if dist > max_radius_km:
continue continue
score = dist + LOAD_PENALTY_KM * load[r["miler_user_id"]] score = dist + load_penalty_km * load[r["miler_user_id"]]
if best_score is None or score < best_score: if best_score is None or score < best_score:
best_score = score best_score = score
best_rider = r best_rider = r

View File

@@ -89,6 +89,10 @@ class FleetAgent(SpecializedAgent):
handlers = { handlers = {
"assign_vehicle": self._assign_vehicle, "assign_vehicle": self._assign_vehicle,
"release_vehicle": self._release_vehicle, "release_vehicle": self._release_vehicle,
# Sent by ExceptionAgent._cancel_order, which knows the order, not
# the vehicle. Had no handler, so every cancellation hit
# _unknown_task and the vehicle stayed marked in use.
"release_vehicle_for_cancel": self._release_vehicle_for_order,
"get_availability": self._get_availability, "get_availability": self._get_availability,
"track_vehicle": self._track_vehicle, "track_vehicle": self._track_vehicle,
"update_location": self._update_location, "update_location": self._update_location,
@@ -181,6 +185,20 @@ class FleetAgent(SpecializedAgent):
return {"status": "released", "vehicle_id": vehicle_id, "hub": vehicle["hub"]} return {"status": "released", "vehicle_id": vehicle_id, "hub": vehicle["hub"]}
async def _release_vehicle_for_order(self, task: AgentTask) -> Dict[str, Any]:
"""Release the vehicle carrying a cancelled order, found by order id."""
order_id = task.data.get("order_id")
for assignment in self._assignments.values():
if assignment.status == "active" and order_id in (assignment.order_ids or []):
return await self._release_vehicle(AgentTask(
task_id=f"{task.task_id}:release",
agent_type=task.agent_type,
task_type="release_vehicle",
data={"vehicle_id": assignment.vehicle_id},
))
return {"status": "no_assignment", "order_id": order_id,
"message": f"No active vehicle assignment carries order {order_id}"}
async def _get_availability(self, task: AgentTask) -> Dict[str, Any]: async def _get_availability(self, task: AgentTask) -> Dict[str, Any]:
hub_id = task.data.get("hub_id") hub_id = task.data.get("hub_id")
vehicle_type = task.data.get("vehicle_type") vehicle_type = task.data.get("vehicle_type")

View File

@@ -8,16 +8,18 @@ from core.types import (
AgentTask, MessageType, Priority, OrderStatus, ZoneType AgentTask, MessageType, Priority, OrderStatus, ZoneType
) )
from core.logger import logger 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): class OrderAgent(SpecializedAgent):
""" """
Order Agent - Manages the entire order lifecycle from intake to validation. Order Agent - Manages the entire order lifecycle from intake to validation.
Writes go to the Doormile Go backend (POST /api/v1/admin/crmbooking). NOT CONNECTED to the Doormile backend (agent registry status: broken).
Reads and status updates also go through the Go API. It was written against /api/v1/admin/crmbooking, which was renamed to
/admin/expressbooking, and every /admin/* route needs a console login's
JWT — this engine holds only the internal key, which /admin/* refuses. So
the helpers below refuse up front instead of sending requests that can only
fail. Wiring it needs an /internal/* booking route on the backend first.
""" """
def __init__(self): def __init__(self):
@@ -70,23 +72,22 @@ class OrderAgent(SpecializedAgent):
# Go API helpers (use shared session + retry from http_client) # # Go API helpers (use shared session + retry from http_client) #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Returns None (what every caller already treats as "no backend result").
NO_BACKEND_ROUTE = ("OrderAgent has no working backend route: /admin/* needs a console JWT "
"and /admin/crmbooking no longer exists")
def _refuse(self, method: str, path: str) -> None:
logger.warning(f"OrderAgent: not calling {method} {path} — {self.NO_BACKEND_ROUTE}")
return None
async def _api_post(self, path: str, payload: Dict) -> Optional[Dict]: async def _api_post(self, path: str, payload: Dict) -> Optional[Dict]:
result = await api_post(f"{GO_API_BASE_URL}{path}", json=payload) return self._refuse("POST", path)
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]: async def _api_get(self, path: str, params: Dict = None) -> Optional[Dict]:
result = await api_get(f"{GO_API_BASE_URL}{path}", params=params) return self._refuse("GET", path)
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]: async def _api_patch(self, path: str, payload: Dict) -> Optional[Dict]:
result = await api_patch(f"{GO_API_BASE_URL}{path}", json=payload) return self._refuse("PATCH", path)
if result is None:
logger.error(f"Go API PATCH {path} returned no response")
return result
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Task handlers # # Task handlers #

View File

@@ -280,7 +280,9 @@ class MasterAgent(Agent):
task_id=f"{order_id}_validate", task_id=f"{order_id}_validate",
agent_type="order", agent_type="order",
task_type="validate_order", task_type="validate_order",
data={"order": order_data} # OrderAgent._validate_order reads `order_id`; sending only
# {"order": ...} made every orchestrated order "not found".
data={"order_id": order_id, "order": order_data}
)) ))
customer_agent = self._sub_agents.get("CUSTOMER_AGENT") customer_agent = self._sub_agents.get("CUSTOMER_AGENT")

66
core/decisions.py Normal file
View File

@@ -0,0 +1,66 @@
"""
Records the engine's model decisions in the backend's decision log.
Until Phase 5 of the plan (krow_talent_app/docs/agent-platform-plan.md) the
stall and assignment-failure decisions existed only as log lines, so the
console's Insights tab could not show them. Each decision is now sent to
``POST /api/v1/internal/agent-decisions`` — the same log routemate's
assignment decisions go to.
Fire-and-forget: the post runs as a background task, so a slow or down
backend never delays an agent's reaction to a stall. A failure is logged by
the HTTP client and otherwise ignored — the decision was still made and acted
on; only its record is missing.
"""
import asyncio
from typing import Any, Dict, Optional, Set
from config.system_config import GO_API_BASE_URL, INTERNAL_API_KEY
from core.http_client import api_post
_pending: Set[asyncio.Task] = set() # keep references so tasks are not garbage-collected
def _as_booking_id(value: Any) -> Optional[int]:
"""The backend's booking_id is an unsigned integer; anything else is omitted."""
try:
n = int(value)
except (TypeError, ValueError):
return None
return n if n > 0 else None
def build_payload(decision_type: str, booking_id: Any, facts: Optional[Dict[str, Any]],
action: str, confidence: float, reasoning: str, model: str) -> Dict[str, Any]:
"""The request body. Pure, so its shape is tested without a network."""
return {
"decision_type": decision_type,
"booking_id": _as_booking_id(booking_id),
"context": {"facts": facts or {}, "model": model},
"decision": {"action": action, "confidence": round(float(confidence), 3)},
"reasoning": reasoning or "",
}
async def _post(payload: Dict[str, Any]) -> None:
await api_post(
f"{GO_API_BASE_URL}/api/v1/internal/agent-decisions",
json=payload,
headers={"X-Internal-Key": INTERNAL_API_KEY},
)
def record_decision(decision_type: str, booking_id: Any, facts: Optional[Dict[str, Any]],
action: str, confidence: float, reasoning: str, model: str) -> Optional[asyncio.Task]:
"""Schedule the record and return at once. Returns the task (for tests), or
None when there is no running loop or no key to authenticate with."""
if not INTERNAL_API_KEY:
return None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return None
task = loop.create_task(_post(build_payload(decision_type, booking_id, facts, action, confidence, reasoning, model)))
_pending.add(task)
task.add_done_callback(_pending.discard)
return task

View File

@@ -102,7 +102,25 @@ _STALL_SCHEMA = {
} }
async def _decide(system: str, schema: dict, valid_actions, context: str): def request_params(model: Optional[str] = None) -> dict:
"""The model-dependent part of a decision request.
`model` is a per-agent override from the agent registry; None means the
engine default (LLM_MODEL). Claude Haiku 4.5 rejects adaptive thinking and
`effort`, so both are left out for it — otherwise a Haiku pin set from the
console would make every decision fail and fall back to the heuristic.
"""
m = model or LLM_MODEL
output_config = {}
params = {"model": m}
if "haiku" not in m:
params["thinking"] = {"type": "adaptive"}
output_config["effort"] = LLM_EFFORT
params["output_config"] = output_config
return params
async def _decide(system: str, schema: dict, valid_actions, context: str, model: Optional[str] = None):
"""Run one structured-output decision against Claude. """Run one structured-output decision against Claude.
Returns (action, reasoning, confidence), or None on any failure (network, Returns (action, reasoning, confidence), or None on any failure (network,
@@ -111,20 +129,17 @@ async def _decide(system: str, schema: dict, valid_actions, context: str):
""" """
resp = None resp = None
last_err = None last_err = None
params = request_params(model)
params["output_config"]["format"] = {"type": "json_schema", "schema": schema}
for attempt in range(2): # initial try + one retry on transient failure for attempt in range(2): # initial try + one retry on transient failure
try: try:
client = _get_client() client = _get_client()
resp = await client.messages.create( resp = await client.messages.create(
model=LLM_MODEL,
max_tokens=LLM_MAX_TOKENS, max_tokens=LLM_MAX_TOKENS,
system=system, system=system,
thinking={"type": "adaptive"},
output_config={
"effort": LLM_EFFORT,
"format": {"type": "json_schema", "schema": schema},
},
messages=[{"role": "user", "content": context}], messages=[{"role": "user", "content": context}],
timeout=LLM_TIMEOUT_S, timeout=LLM_TIMEOUT_S,
**params,
) )
break break
except Exception as e: except Exception as e:
@@ -154,10 +169,10 @@ async def _decide(system: str, schema: dict, valid_actions, context: str):
return None return None
async def decide_stall_response(context: str) -> Optional[StallDecision]: async def decide_stall_response(context: str, model: Optional[str] = None) -> Optional[StallDecision]:
"""Ask Claude how to handle a stalled miler. None on failure (caller falls """Ask Claude how to handle a stalled miler. None on failure (caller falls
back to deterministic behaviour).""" back to deterministic behaviour). `model` overrides LLM_MODEL."""
r = await _decide(_STALL_SYSTEM, _STALL_SCHEMA, _VALID_ACTIONS, context) r = await _decide(_STALL_SYSTEM, _STALL_SCHEMA, _VALID_ACTIONS, context, model)
return StallDecision(*r) if r else None return StallDecision(*r) if r else None
@@ -224,8 +239,8 @@ def build_assignment_failure_context(facts, *, now=None) -> str:
return "\n".join(lines) return "\n".join(lines)
async def decide_assignment_failure(context: str) -> Optional[AssignmentDecision]: async def decide_assignment_failure(context: str, model: Optional[str] = None) -> Optional[AssignmentDecision]:
"""Ask Claude how to react to a failed assignment. None on failure (caller """Ask Claude how to react to a failed assignment. None on failure (caller
falls back to deterministic behaviour).""" falls back to deterministic behaviour). `model` overrides LLM_MODEL."""
r = await _decide(_ASSIGNMENT_SYSTEM, _ASSIGNMENT_SCHEMA, _ASSIGNMENT_ACTIONS, context) r = await _decide(_ASSIGNMENT_SYSTEM, _ASSIGNMENT_SCHEMA, _ASSIGNMENT_ACTIONS, context, model)
return AssignmentDecision(*r) if r else None return AssignmentDecision(*r) if r else None

122
core/registry.py Normal file
View File

@@ -0,0 +1,122 @@
"""
The agent registry, as this engine sees it.
Settings an operator changes in the console (Settings → Skills & Tools) are
stored in the Doormile backend's agent registry. This module polls
``GET /api/v1/internal/ai/registry`` and answers, at the moment an agent needs
it, three questions:
registry.autonomous(agent_id, env_default) may this agent act on its own?
registry.model(agent_id) which Claude model, if pinned?
registry.skill_enabled(skill_id) is this behaviour switched on?
registry.threshold(skill_id, key, default) the tuned value of a knob
Precedence — deliberate, and the same for every setting:
registry loaded → the registry's value (it is the operator's decision)
not yet / never → the env default this engine always had
So a backend that is down, unreachable, or not yet deployed leaves the engine
exactly as it behaved before the registry existed. Once the registry has been
read, the last good copy is kept through later failures: a flapping backend
does not flip autonomy back to an env default mid-shift.
Polling uses the ETag the backend sends; an unchanged registry costs a 304
with no body. Before Phase 5 of the plan (krow_talent_app/docs/
agent-platform-plan.md) every one of these settings was a module constant read
from the environment at import time, so a change needed a redeploy.
"""
import asyncio
import os
from typing import Any, Dict, Optional
import aiohttp
from core.logger import logger
POLL_SECONDS = float(os.getenv("REGISTRY_POLL_SECONDS", "30"))
class RegistryClient:
def __init__(self):
self._agents: Dict[str, Dict[str, Any]] = {}
self._skills: Dict[str, Dict[str, Any]] = {}
self._etag: Optional[str] = None
self.loaded = False
# ── Reading (sync, cheap, safe to call on every event) ──────────────────
def autonomous(self, agent_id: str, env_default: bool) -> bool:
if not self.loaded or agent_id not in self._agents:
return env_default
return bool(self._agents[agent_id].get("autonomous", False))
def model(self, agent_id: str) -> Optional[str]:
"""A pinned model id, or None for the engine's own LLM_MODEL."""
if not self.loaded:
return None
return self._agents.get(agent_id, {}).get("model") or None
def skill_enabled(self, skill_id: str, default: bool = True) -> bool:
if not self.loaded or skill_id not in self._skills:
return default
return bool(self._skills[skill_id].get("enabled", default))
def threshold(self, skill_id: str, key: str, default: float) -> float:
if not self.loaded:
return default
value = (self._skills.get(skill_id, {}).get("thresholds") or {}).get(key)
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else default
# ── Loading ──────────────────────────────────────────────────────────────
def apply(self, snapshot: Dict[str, Any]) -> None:
"""Adopt a registry snapshot (the `data` of /internal/ai/registry)."""
agents = {a["agentid"]: a for a in snapshot.get("agents") or [] if a.get("agentid")}
skills = {s["skillid"]: s for s in snapshot.get("skills") or [] if s.get("skillid")}
self._agents, self._skills = agents, skills
if not self.loaded:
logger.info(f"Agent registry loaded: {len(agents)} agents, {len(skills)} skills")
self.loaded = True
async def fetch_once(self, session: aiohttp.ClientSession, base_url: str, api_key: str) -> str:
"""One poll. Returns 'updated', 'unchanged', 'unavailable' or 'refused'."""
headers = {"X-Internal-Key": api_key}
if self._etag:
headers["If-None-Match"] = self._etag
try:
async with session.get(f"{base_url}/api/v1/internal/ai/registry", headers=headers,
timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 304:
return "unchanged"
if resp.status in (401, 403):
return "refused"
if resp.status != 200:
return "unavailable"
body = await resp.json()
self.apply(body.get("data") or {})
self._etag = resp.headers.get("ETag")
return "updated"
except Exception as exc: # network, timeout, bad JSON — keep the last good copy
logger.debug(f"Agent registry poll failed: {exc}")
return "unavailable"
async def run(self, base_url: str, api_key: str, poll_seconds: float = POLL_SECONDS) -> None:
"""Poll forever. Never raises; the engine runs on env defaults meanwhile."""
if not api_key:
logger.warning("INTERNAL_API_KEY unset — agent registry not read; running on env defaults")
return
last = None
async with aiohttp.ClientSession() as session:
while True:
outcome = await self.fetch_once(session, base_url, api_key)
if outcome != last and outcome in ("refused", "unavailable"):
logger.warning(
f"Agent registry {outcome} at {base_url}; "
+ ("keeping the last loaded copy" if self.loaded else "running on env defaults")
)
last = outcome
await asyncio.sleep(poll_seconds)
registry = RegistryClient()

View File

@@ -16,6 +16,9 @@ class MessageType(str, Enum):
ORDER_DELIVERED = "ORDER_DELIVERED" ORDER_DELIVERED = "ORDER_DELIVERED"
ORDER_CANCELLED = "ORDER_CANCELLED" ORDER_CANCELLED = "ORDER_CANCELLED"
ORDER_DELAYED = "ORDER_DELAYED" ORDER_DELAYED = "ORDER_DELAYED"
# OrderAgent._update_status publishes this; it was missing from the enum, so
# every update_status task raised AttributeError.
ORDER_STATUS_UPDATE = "ORDER_STATUS_UPDATE"
HUB_STATUS_UPDATE = "HUB_STATUS_UPDATE" HUB_STATUS_UPDATE = "HUB_STATUS_UPDATE"
VEHICLE_ASSIGNED = "VEHICLE_ASSIGNED" VEHICLE_ASSIGNED = "VEHICLE_ASSIGNED"
ROUTE_OPTIMIZED = "ROUTE_OPTIMIZED" ROUTE_OPTIMIZED = "ROUTE_OPTIMIZED"

View File

@@ -28,6 +28,8 @@ from core.logger import logger
from core.agent import MasterAgent, Agent from core.agent import MasterAgent, Agent
from core.message_bus import message_bus from core.message_bus import message_bus
from core.http_client import close_session from core.http_client import close_session
from core.registry import registry
from config.system_config import GO_API_BASE_URL, INTERNAL_API_KEY
from core.types import AgentTask, MessageType, Priority from core.types import AgentTask, MessageType, Priority
from agents.order_agent import OrderAgent from agents.order_agent import OrderAgent
@@ -125,6 +127,11 @@ async def production_mode():
system = LogiFlowAI() system = LogiFlowAI()
system.initialize_agents() system.initialize_agents()
# Operator settings from the backend's agent registry (autonomy, model,
# skills on/off, thresholds). Polls with ETag; until it answers, and if it
# never does, every agent runs on its env defaults exactly as before.
registry_task = asyncio.create_task(registry.run(GO_API_BASE_URL, INTERNAL_API_KEY)) # noqa: F841 — cancelled at shutdown
logger.info("Infrastructure connected. Press Ctrl+C to stop.") logger.info("Infrastructure connected. Press Ctrl+C to stop.")
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()

6
requirements-dev.txt Normal file
View File

@@ -0,0 +1,6 @@
# Test-only dependencies. Not installed in the production image (the Dockerfile
# installs requirements.txt only). Install locally with:
# pip install -r requirements-dev.txt
-r requirements.txt
pytest>=8.0.0
pytest-asyncio>=0.23.0

View File

@@ -213,7 +213,7 @@ def make_handler_agent(redis, decision):
box = FakeMessages() box = FakeMessages()
agent.send_message = box.send_message agent.send_message = box.send_message
calls = [] calls = []
async def fake_decide(context): async def fake_decide(context, model=None):
calls.append(context) calls.append(context)
return decision return decision
mod.decide_assignment_failure = fake_decide mod.decide_assignment_failure = fake_decide

View File

@@ -0,0 +1,413 @@
"""
Tests for Phase 5 of krow_talent_app/docs/agent-platform-plan.md: the engine
reads operator settings from the backend's agent registry, records its model
decisions, and the six agent-to-agent message bugs are fixed.
No network, no NATS, no Redis — every external edge is faked.
Run:
python -m unittest discover -s tests
"""
import json
import unittest
from datetime import datetime
from unittest.mock import patch
import core.decisions as decisions
import core.llm as llm
from core.message_bus import message_bus
from core.registry import RegistryClient, registry
from core.types import AgentMessage, AgentTask, MessageType
SNAPSHOT = {
"agents": [
{"agentid": "EXCEPTION_AGENT", "autonomous": True, "model": "claude-sonnet-5-5"},
{"agentid": "DISPATCH_AGENT", "autonomous": False, "model": ""},
],
"skills": [
{"skillid": "stall_response", "enabled": True,
"thresholds": {"stallMinutes": 15, "reassignConfidence": 0.9, "bogus": "x", "flag": True}},
{"skillid": "customer_notifications", "enabled": False},
],
}
def _task(task_type, **data):
return AgentTask(task_id="t", agent_type="x", task_type=task_type, data=data)
class _RegistryIsolation:
"""Save and restore the shared registry singleton the agents read."""
def _save_registry(self):
self._saved = (registry._agents, registry._skills, registry._etag, registry.loaded)
def _restore_registry(self):
registry._agents, registry._skills, registry._etag, registry.loaded = self._saved
# ── RegistryClient: precedence ───────────────────────────────────────────────
class TestRegistryPrecedence(unittest.TestCase):
def test_env_defaults_before_load(self):
r = RegistryClient()
self.assertTrue(r.autonomous("EXCEPTION_AGENT", True))
self.assertFalse(r.autonomous("EXCEPTION_AGENT", False))
self.assertIsNone(r.model("EXCEPTION_AGENT"))
self.assertTrue(r.skill_enabled("customer_notifications"))
self.assertFalse(r.skill_enabled("anything", default=False))
self.assertEqual(r.threshold("stall_response", "stallMinutes", 10), 10)
def test_registry_wins_once_loaded(self):
r = RegistryClient()
r.apply(SNAPSHOT)
self.assertTrue(r.loaded)
self.assertTrue(r.autonomous("EXCEPTION_AGENT", False))
self.assertFalse(r.autonomous("DISPATCH_AGENT", True))
self.assertEqual(r.model("EXCEPTION_AGENT"), "claude-sonnet-5-5")
self.assertIsNone(r.model("DISPATCH_AGENT")) # "" means engine default
self.assertFalse(r.skill_enabled("customer_notifications"))
self.assertEqual(r.threshold("stall_response", "stallMinutes", 10), 15)
self.assertEqual(r.threshold("stall_response", "reassignConfidence", 0.75), 0.9)
def test_unknown_ids_fall_back(self):
r = RegistryClient()
r.apply(SNAPSHOT)
self.assertTrue(r.autonomous("HUB_AGENT", True))
self.assertTrue(r.skill_enabled("not_in_registry"))
self.assertEqual(r.threshold("not_in_registry", "k", 3), 3)
def test_non_numeric_thresholds_ignored(self):
r = RegistryClient()
r.apply(SNAPSHOT)
self.assertEqual(r.threshold("stall_response", "bogus", 7), 7)
self.assertEqual(r.threshold("stall_response", "flag", 7), 7) # bool is not a number here
def test_rows_without_ids_skipped(self):
r = RegistryClient()
r.apply({"agents": [{"autonomous": True}], "skills": [{"enabled": False}]})
self.assertTrue(r.loaded)
self.assertEqual(r._agents, {})
self.assertEqual(r._skills, {})
# ── RegistryClient: polling ──────────────────────────────────────────────────
class FakeResp:
def __init__(self, status, body=None, etag=None, raise_on_json=False):
self.status = status
self._body = body
self.headers = {"ETag": etag} if etag else {}
self._raise = raise_on_json
async def json(self):
if self._raise:
raise ValueError("bad json")
return self._body
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class FakeSession:
def __init__(self, *responses, exc=None):
self._responses = list(responses)
self._exc = exc
self.calls = []
def get(self, url, headers=None, timeout=None):
self.calls.append((url, dict(headers or {})))
if self._exc:
raise self._exc
return self._responses.pop(0)
class TestRegistryFetch(unittest.IsolatedAsyncioTestCase):
async def test_200_then_304_uses_etag(self):
r = RegistryClient()
s = FakeSession(FakeResp(200, {"data": SNAPSHOT}, etag='"v1"'), FakeResp(304))
self.assertEqual(await r.fetch_once(s, "http://api", "k"), "updated")
self.assertTrue(r.loaded)
self.assertEqual(await r.fetch_once(s, "http://api", "k"), "unchanged")
url, first = s.calls[0]
self.assertEqual(url, "http://api/api/v1/internal/ai/registry")
self.assertEqual(first["X-Internal-Key"], "k")
self.assertNotIn("If-None-Match", first)
self.assertEqual(s.calls[1][1]["If-None-Match"], '"v1"')
async def test_refused_and_unavailable_keep_last_copy(self):
r = RegistryClient()
r.apply(SNAPSHOT)
for resp, want in ((FakeResp(401), "refused"), (FakeResp(403), "refused"),
(FakeResp(500), "unavailable"), (FakeResp(200, raise_on_json=True), "unavailable")):
self.assertEqual(await r.fetch_once(FakeSession(resp), "http://api", "k"), want)
self.assertEqual(await r.fetch_once(FakeSession(exc=OSError("down")), "http://api", "k"), "unavailable")
# Still the operator's settings, not the env defaults.
self.assertTrue(r.autonomous("EXCEPTION_AGENT", False))
self.assertFalse(r.skill_enabled("customer_notifications"))
async def test_run_without_key_returns_at_once(self):
r = RegistryClient()
await r.run("http://api", "", poll_seconds=0) # must not loop forever
self.assertFalse(r.loaded)
# ── Decision log payload ─────────────────────────────────────────────────────
class TestDecisions(unittest.IsolatedAsyncioTestCase):
def test_build_payload_shape(self):
p = decisions.build_payload("stall_response", "42", {"eta": 5}, "reassign", 0.87654, "slow", "m")
self.assertEqual(p, {
"decision_type": "stall_response",
"booking_id": 42,
"context": {"facts": {"eta": 5}, "model": "m"},
"decision": {"action": "reassign", "confidence": 0.877},
"reasoning": "slow",
})
def test_bad_booking_ids_omitted(self):
for bad in (None, "", "abc", 0, -3):
self.assertIsNone(decisions.build_payload("t", bad, None, "a", 0, None, "m")["booking_id"])
self.assertEqual(decisions.build_payload("t", 7, None, "a", 0, None, "m")["context"]["facts"], {})
def test_no_running_loop_returns_none(self):
with patch.object(decisions, "INTERNAL_API_KEY", "k"):
self.assertIsNone(decisions.record_decision("t", 1, {}, "a", 0.5, "r", "m"))
async def test_no_key_returns_none(self):
with patch.object(decisions, "INTERNAL_API_KEY", ""):
self.assertIsNone(decisions.record_decision("t", 1, {}, "a", 0.5, "r", "m"))
async def test_posts_to_decision_log(self):
sent = []
async def fake_post(url, json=None, headers=None, **kw):
sent.append((url, json, headers))
with patch.object(decisions, "INTERNAL_API_KEY", "k"), \
patch.object(decisions, "GO_API_BASE_URL", "http://api"), \
patch.object(decisions, "api_post", fake_post):
task = decisions.record_decision("assignment_failure", 9, {}, "notify", 0.5, "r", "m")
self.assertIsNotNone(task)
await task
url, body, headers = sent[0]
self.assertEqual(url, "http://api/api/v1/internal/agent-decisions")
self.assertEqual(body["booking_id"], 9)
self.assertEqual(headers, {"X-Internal-Key": "k"})
# ── Model-dependent request params ──────────────────────────────────────────
class TestRequestParams(unittest.TestCase):
def test_default_model_has_thinking_and_effort(self):
p = llm.request_params()
self.assertEqual(p["model"], llm.LLM_MODEL)
self.assertEqual(p["thinking"], {"type": "adaptive"})
self.assertIn("effort", p["output_config"])
def test_override_model_used(self):
self.assertEqual(llm.request_params("claude-sonnet-5-5")["model"], "claude-sonnet-5-5")
def test_haiku_omits_thinking_and_effort(self):
p = llm.request_params("claude-haiku-4-5")
self.assertNotIn("thinking", p)
self.assertNotIn("effort", p["output_config"])
def test_calls_do_not_share_output_config(self):
a, b = llm.request_params(), llm.request_params()
a["output_config"]["format"] = {}
self.assertNotIn("format", b["output_config"])
# ── ExceptionAgent: registry gates and the LLM-down fallback ────────────────
class FakeStallMsg:
def __init__(self, payload):
self.data = json.dumps(payload).encode()
class TestExceptionAgentRegistry(_RegistryIsolation, unittest.IsolatedAsyncioTestCase):
def setUp(self):
from agents import exception_agent as mod
self.mod = mod
self._save_registry()
self._orig_decide = mod.decide_stall_response
self._orig_record = mod.record_decision
def tearDown(self):
self._restore_registry()
self.mod.decide_stall_response = self._orig_decide
self.mod.record_decision = self._orig_record
def _agent(self, decision=None):
from agents.exception_agent import ExceptionAgent
agent = ExceptionAgent.__new__(ExceptionAgent)
agent.acts = []
async def claim(_b):
return True
async def facts(_m, _b):
return {}
async def reassign(b, reason):
agent.acts.append("reassign")
return True
async def notify(b, m):
agent.acts.append("notify")
return True
async def escalate(m, b, mins, d):
agent.acts.append(("escalate", d.action))
agent._claim_stall_handled = claim
agent._gather_stall_facts = facts
agent._reassign = reassign
agent._notify_customer = notify
agent._escalate_to_human = escalate
agent._record_stall_exception = lambda *a, **k: None
self.models = []
self.recorded = []
async def fake_decide(context, model=None):
self.models.append(model)
return decision
self.mod.decide_stall_response = fake_decide
self.mod.record_decision = lambda *a: self.recorded.append(a)
return agent
def _load(self, autonomous, enabled=True, confidence=0.75):
registry.apply({
"agents": [{"agentid": "EXCEPTION_AGENT", "autonomous": autonomous, "model": "claude-sonnet-5-5"}],
"skills": [{"skillid": "stall_response", "enabled": enabled,
"thresholds": {"reassignConfidence": confidence}}],
})
async def _stall(self, agent):
await agent._on_miler_stalled(FakeStallMsg({"miler_id": "m1", "booking_id": "5", "minutes_stalled": 12}))
async def test_llm_down_not_autonomous_escalates(self):
self._load(autonomous=False)
agent = self._agent(decision=None)
await self._stall(agent)
self.assertEqual(agent.acts, [("escalate", "escalate")])
async def test_llm_down_autonomous_reassigns(self):
self._load(autonomous=True)
agent = self._agent(decision=None)
await self._stall(agent)
self.assertEqual(agent.acts, ["reassign", "notify"])
async def test_skill_disabled_does_nothing(self):
self._load(autonomous=True, enabled=False)
agent = self._agent(decision=None)
await self._stall(agent)
self.assertEqual(agent.acts, [])
self.assertEqual(self.models, []) # the model was never asked
async def test_model_pin_and_decision_recorded(self):
self._load(autonomous=True, confidence=0.95)
agent = self._agent(decision=llm.StallDecision("reassign", "stuck", 0.9))
await self._stall(agent)
self.assertEqual(self.models, ["claude-sonnet-5-5"])
# 0.9 is below the registry's 0.95, so the reassign goes to a human.
self.assertEqual(agent.acts, [("escalate", "reassign")])
self.assertEqual(self.recorded[0][0], "stall_response")
self.assertEqual(self.recorded[0][-1], "claude-sonnet-5-5")
async def test_stall_minutes_from_registry(self):
registry.loaded = False
self.assertEqual(self.mod.stall_minutes(), self.mod.STALL_MINUTES)
registry.apply({"skills": [{"skillid": "stall_response", "enabled": True, "thresholds": {"stallMinutes": 20}}]})
self.assertEqual(self.mod.stall_minutes(), 20)
# ── CustomerAgent: skill gate and recorded events ───────────────────────────
class TestCustomerAgent(_RegistryIsolation, unittest.IsolatedAsyncioTestCase):
def setUp(self):
self._save_registry()
async def asyncTearDown(self):
self._restore_registry()
message_bus.unregister_agent("CUSTOMER_AGENT")
async def test_notifications_disabled_skips_send(self):
from agents.customer_agent import CustomerAgent
registry.apply({"skills": [{"skillid": "customer_notifications", "enabled": False}]})
result = await CustomerAgent()._send_notification(_task("send_notification", order_id="O1"))
self.assertEqual(result["status"], "skipped")
async def test_cancel_and_notification_events_recorded(self):
from agents.customer_agent import CustomerAgent
agent = CustomerAgent()
for mt in (MessageType.ORDER_CANCELLED, MessageType.NOTIFICATION_SENT, MessageType.HEARTBEAT):
await agent.handle_message(AgentMessage("id", "EXCEPTION_AGENT", "CUSTOMER_AGENT", mt,
{"order_id": "O9"}, datetime.now()))
self.assertEqual([e["type"] for e in agent._received_events], ["ORDER_CANCELLED", "NOTIFICATION_SENT"])
self.assertEqual(agent._received_events[0]["order_id"], "O9")
# ── Message-bug fixes ────────────────────────────────────────────────────────
class TestMessageFixes(unittest.IsolatedAsyncioTestCase):
async def asyncTearDown(self):
for agent_id in ("FLEET_AGENT", "ORDER_AGENT", "JARVIS", "FAKE_ORDER", "FAKE_CUSTOMER"):
message_bus.unregister_agent(agent_id)
async def test_fleet_release_for_cancel_finds_vehicle_by_order(self):
from agents.fleet_agent import FleetAgent, VehicleAssignment
fleet = FleetAgent()
vehicle_id = next(iter(fleet._vehicles))
fleet._vehicles[vehicle_id]["status"] = "in_transit"
fleet._assignments["A1"] = VehicleAssignment(
"A1", vehicle_id, "R1", ["O1", "O2"], datetime.now(), datetime.now(), {})
missing = await fleet.handle_task(_task("release_vehicle_for_cancel", order_id="NOPE"))
self.assertEqual(missing["status"], "no_assignment")
await fleet.handle_task(_task("release_vehicle_for_cancel", order_id="O2"))
self.assertEqual(fleet._vehicles[vehicle_id]["status"], "available")
self.assertEqual(fleet._assignments["A1"].status, "returned")
async def test_order_agent_refuses_backend_calls(self):
from agents.order_agent import OrderAgent
agent = OrderAgent()
with patch("core.http_client.api_post") as post, patch("core.http_client.api_get") as get:
self.assertIsNone(await agent._api_post("/api/v1/admin/crmbooking", {}))
self.assertIsNone(await agent._api_get("/api/v1/admin/x"))
self.assertIsNone(await agent._api_patch("/api/v1/admin/x", {}))
post.assert_not_called()
get.assert_not_called()
async def test_jarvis_validate_payload_carries_order_id(self):
from core.agent import MasterAgent
class Sink:
def __init__(self, agent_id):
self.agent_id = agent_id
self.tasks = []
async def submit_task(self, task):
self.tasks.append(task)
jarvis = MasterAgent()
order, customer = Sink("ORDER_AGENT"), Sink("CUSTOMER_AGENT")
jarvis._sub_agents = {"ORDER_AGENT": order, "CUSTOMER_AGENT": customer}
await jarvis.handle_task(_task("orchestrate_order", order={"order_id": "O7"}))
self.assertEqual(order.tasks[0].task_type, "validate_order")
self.assertEqual(order.tasks[0].data["order_id"], "O7")
def test_order_status_update_message_type_exists(self):
self.assertEqual(MessageType.ORDER_STATUS_UPDATE.value, "ORDER_STATUS_UPDATE")
if __name__ == "__main__":
unittest.main()