update on the agents and registry side

This commit is contained in:
2026-09-30 12:03:13 +05:30
parent 92e8da2842
commit 53199127ba
73 changed files with 7268 additions and 1134 deletions

View File

@@ -0,0 +1,123 @@
// ==============================|| Doormile AI — Skill Registry ||============================== //
//
// The console's eight monitoring skills: their RULES live in code
// (./definitions), their SETTINGS — enabled, thresholds — live in the backend
// agent registry (/admin/ai/skills, edited in Settings → Skills & Tools). This
// class joins the two.
//
// On the feat/agentic-ops-layer branch these settings were kept in the
// browser's localStorage, so a threshold tuned on one machine changed nothing
// on any other, and two operators could look at the same board through
// different rules. Ported onto main in Phase 3 of docs/agent-platform-plan.md,
// the settings come from the registry instead, and nothing is stored locally.
//
// Until the registry answers (first load, a network error, a partner login
// that is not allowed to read it) every skill runs on its code defaults, and
// `getSource()` says so, so the banner can tell the operator which settings it
// is running on rather than implying they are the tuned ones.
import { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
import { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
import { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
import { HighValueCodSkill } from './definitions/HighValueCodSkill';
import { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
import { HubCongestionSkill } from './definitions/HubCongestionSkill';
import { LateDispatchSkill } from './definitions/LateDispatchSkill';
import { CashExposureSkill } from './definitions/CashExposureSkill';
export const DEFAULT_SKILLS = [
SlaGuardianSkill,
DoorstepStallSkill,
FleetBalancerSkill,
HighValueCodSkill,
RiderBatterySafetySkill,
HubCongestionSkill,
LateDispatchSkill,
CashExposureSkill
];
/** A stored value outside the definition's range falls back to its default. */
const inRange = (config, v) =>
typeof v === 'number' && Number.isFinite(v) && v >= config.min && v <= config.max;
class SkillRegistryClass {
constructor() {
this.skills = new Map();
this.listeners = new Set();
this.config = {}; // { [skillId]: { enabled: boolean, thresholds: { [key]: number } } }
this.source = 'defaults';
DEFAULT_SKILLS.forEach((skill) => this.skills.set(skill.id, skill));
}
/**
* Adopt the registry's settings. `rows` is the /admin/ai/skills response;
* rows for skills this console does not implement are ignored.
*/
applyRegistryConfig(rows) {
const next = {};
(Array.isArray(rows) ? rows : []).forEach((row) => {
if (!row || !this.skills.has(row.skillid)) return;
next[row.skillid] = {
enabled: Boolean(row.enabled),
thresholds: row.thresholds && typeof row.thresholds === 'object' ? row.thresholds : {}
};
});
this.config = next;
this.source = 'registry';
this.notify();
}
/** Forget the registry's settings and run on code defaults. */
clearRegistryConfig() {
if (this.source === 'defaults' && !Object.keys(this.config).length) return;
this.config = {};
this.source = 'defaults';
this.notify();
}
/** 'registry' when running on the tuned settings, 'defaults' otherwise. */
getSource() {
return this.source;
}
getAllSkills() {
return Array.from(this.skills.values()).map((skill) => {
const cfg = this.config[skill.id];
const enabled = cfg ? cfg.enabled : (skill.defaultEnabled ?? true);
const effectiveThresholds = {};
Object.entries(skill.thresholds || {}).forEach(([key, config]) => {
const stored = cfg?.thresholds?.[key];
effectiveThresholds[key] = { ...config, currentValue: inRange(config, stored) ? stored : config.value };
});
return { ...skill, enabled, effectiveThresholds };
});
}
getActiveSkills() {
return this.getAllSkills().filter((s) => s.enabled);
}
getSkill(skillId) {
return this.getAllSkills().find((s) => s.id === skillId);
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
const all = this.getAllSkills();
this.listeners.forEach((fn) => {
try {
fn(all);
} catch {
// One listener failing must not stop the others hearing the change.
}
});
}
}
export const SkillRegistry = new SkillRegistryClass();

View File

@@ -0,0 +1,161 @@
// ==============================|| Skill: Cash Exposure Agent ||============================== //
//
// Monitors the total live COD (Cash on Delivery) cash a single rider is carrying
// across ALL their active orders. When total exposure exceeds the safe limit,
// proposes a mandatory cash handoff stop at the nearest hub.
//
// This is distinct from HighValueCodSkill, which monitors per-order value.
// This skill monitors per-rider TOTAL cash accumulation across their full run.
const ACTIVE_COD_STATUSES = new Set(['accepted', 'picked', 'active', 'arrived']);
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const CashExposureSkill = {
id: 'skill_cash_exposure',
name: 'Cash Exposure Agent',
description: 'Monitors total live COD cash per rider across all active orders. Flags when a rider carries unsafe cash levels and proposes hub handoff.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no COD amount per order: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no COD amount per order: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
maxCashPerRider: {
label: 'Max Safe Cash Per Rider',
description: 'Maximum total COD cash (₹) a single rider should carry simultaneously across all active orders.',
value: 10000,
min: 2000,
max: 50000,
step: 1000,
unit: '₹'
},
warningCashPercent: {
label: 'Warning Threshold (%)',
description: 'Percentage of max cash limit at which to issue a warning (before it becomes critical).',
value: 75,
min: 50,
max: 95,
step: 5,
unit: '%'
}
},
instructions: `
- A rider carrying excessive COD cash is a major financial risk if the phone dies, the rider is delayed, or there is a dispute.
- Flag riders above the cash threshold for a mandatory hub cash handoff before continuing deliveries.
- Critical: stop new COD orders being assigned to flagged riders until handoff is confirmed.
`,
tools: [
{
name: 'enforce_cash_handoff',
description: 'Proposes a mandatory cash handoff stop at the nearest hub for an overexposed rider.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Rider user ID carrying excessive cash' },
totalCash: { type: 'number', description: 'Total COD cash the rider currently holds (₹)' },
hubId: { type: 'string', description: 'Nearest hub for handoff' }
},
required: ['milerId', 'totalCash']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'cash_handoff',
milerId: args.milerId,
totalCash: args.totalCash,
hubId: args.hubId || 'nearest',
blastRadius: `Reroutes rider to nearest hub for cash handoff of ₹${args.totalCash?.toLocaleString('en-IN')}. Pauses new COD order assignments to this rider until confirmed.`
}
})
}
],
evaluate: (rows = [], _now, currentThresholds = {}) => {
const maxCash = currentThresholds.maxCashPerRider ?? 10000;
const warningPct = (currentThresholds.warningCashPercent ?? 75) / 100;
const warningLimit = maxCash * warningPct;
const findings = [];
// Group active COD orders by rider
const byRider = {};
rows
.filter((r) =>
ACTIVE_COD_STATUSES.has(String(r?.orderstatus || '').toLowerCase()) &&
r?.paymenttype &&
String(r.paymenttype).toLowerCase().includes('cod') &&
r?.userid
)
.forEach((r) => {
const uid = r.userid;
if (!byRider[uid]) {
byRider[uid] = { riderId: uid, riderName: r.ridername || r.miler_name || `Rider ${uid}`, orders: [] };
}
byRider[uid].orders.push(r);
});
const criticalRiders = [];
const warningRiders = [];
Object.values(byRider).forEach((riderData) => {
const total = riderData.orders.reduce((sum, r) => {
const amt = parseFloat(r.cod_amount || r.orderamount || r.totalamount || 0);
return sum + (isNaN(amt) ? 0 : amt);
}, 0);
riderData.totalCash = total;
if (total >= maxCash) {
criticalRiders.push(riderData);
} else if (total >= warningLimit) {
warningRiders.push(riderData);
}
});
criticalRiders.sort((a, b) => b.totalCash - a.totalCash);
warningRiders.sort((a, b) => b.totalCash - a.totalCash);
if (criticalRiders.length) {
findings.push({
id: 'cash_exposure_critical',
skillId: 'skill_cash_exposure',
severity: 'critical',
title: `${criticalRiders.length} rider${criticalRiders.length === 1 ? '' : 's'} carrying ≥₹${maxCash.toLocaleString('en-IN')} in COD cash`,
why: `Total COD cash per rider exceeds the safe limit of ₹${maxCash.toLocaleString('en-IN')}. Immediate hub handoff required.`,
count: criticalRiders.length,
rows: criticalRiders.flatMap((rd) => rd.orders),
detail: criticalRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} active COD order${rd.orders.length === 1 ? '' : 's'}`
),
proposal: {
label: 'Enforce cash handoff at hub',
tool: 'enforce_cash_handoff',
scope: criticalRiders.flatMap((rd) => rd.orders),
blastRadius: 'Reroutes affected riders to nearest hub for mandatory cash handoff. New COD assignments paused.'
}
});
}
if (warningRiders.length) {
findings.push({
id: 'cash_exposure_warning',
skillId: 'skill_cash_exposure',
severity: 'warning',
title: `${warningRiders.length} rider${warningRiders.length === 1 ? '' : 's'} approaching cash limit (>${Math.round(warningPct * 100)}% of ₹${maxCash.toLocaleString('en-IN')})`,
why: `Total COD cash is approaching the safe threshold. Plan a hub handoff stop for these riders.`,
count: warningRiders.length,
rows: warningRiders.flatMap((rd) => rd.orders),
detail: warningRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} orders`
)
});
}
return findings;
}
};

View File

@@ -0,0 +1,90 @@
// ==============================|| Skill: Doorstep Stall Rescuer ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const DoorstepStallSkill = {
id: 'skill_doorstep_stall',
name: 'Doorstep Stall Rescuer',
description: 'Flags riders who stamped arrival at consignee doorstep but have been stalled with no progress.',
category: 'rider_operations',
icon: 'MapPinOff',
defaultEnabled: true,
thresholds: {
arrivedStalledMin: {
label: 'Doorstep Stall Timeout',
description: 'Minutes after arriving at customer location before flagging as stalled.',
value: 20,
min: 10,
max: 60,
step: 5,
unit: 'min'
}
},
instructions: `
- When a rider is stalled at a delivery location, suggest verifying customer contact or initiating automated phone verification.
`,
tools: [
{
name: 'ping_stalled_rider',
description: 'Sends an in-app ping to check if the rider needs help with gate access or customer contact.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Miler ID to reach' },
message: { type: 'string', description: 'Optional prompt text' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'notify_miler',
milerId: args.milerId,
blastRadius: `Pings rider #${args.milerId} with doorstep assistance prompt.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const stallLimit = currentThresholds.arrivedStalledMin ?? 20;
const stalled = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'arrived' && r.reachedat)
.map((r) => ({ row: r, waitingMin: minutesBetween(r.reachedat, now) }))
.filter((m) => m.waitingMin !== null && m.waitingMin >= stallLimit)
.sort((a, b) => b.waitingMin - a.waitingMin);
if (!stalled.length) return [];
return [
{
id: 'stalled_at_door',
skillId: 'skill_doorstep_stall',
severity: 'critical',
title: `${stalled.length} rider${stalled.length === 1 ? '' : 's'} stalled at door for > ${stallLimit}m`,
why: 'Rider app reported arrival but delivery confirmation has not completed. Possible gate, address, or absent consignee issue.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — ${m.row.ridername || 'Rider'} waiting ${m.waitingMin} min`),
proposal: {
label: 'Message waiting riders & support',
tool: 'notifyRider',
scope: stalled.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an assistance check-in to stalled delivery riders.'
}
}
];
}
};

View File

@@ -0,0 +1,87 @@
// ==============================|| Skill: Fleet Load Balancer ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
export const FleetBalancerSkill = {
id: 'skill_fleet_balancer',
name: 'Fleet Load Balancer',
description: 'Prevents rider overload by flagging riders at maximum active parcel capacity while queue work waits.',
category: 'fleet_optimization',
icon: 'Scale',
defaultEnabled: true,
thresholds: {
riderActiveCap: {
label: 'Rider Active Capacity Cap',
description: 'Maximum concurrent active orders a single rider should carry simultaneously.',
value: 3,
min: 1,
max: 6,
step: 1,
unit: 'orders'
}
},
instructions: `
- Keep rider concurrent workload at or below the specified active cap to prevent cumulative delivery delays.
- Proactively redistribute un-picked orders from overloaded riders to available nearby fleet.
`,
tools: [
{
name: 'balance_rider_load',
description: 'Reallocates queued unpicked orders from a saturated rider to an available rider.',
parameters: {
type: 'object',
properties: {
fromMilerId: { type: 'number', description: 'Overloaded miler ID' },
toMilerId: { type: 'number', description: 'Available miler ID' }
},
required: ['fromMilerId', 'toMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'rebalance_workload',
fromMilerId: args.fromMilerId,
toMilerId: args.toMilerId,
blastRadius: `Transfers queued parcels from Rider #${args.fromMilerId} to Rider #${args.toMilerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const activeCap = currentThresholds.riderActiveCap ?? 3;
const safeRows = Array.isArray(rows) ? rows : [];
const unassignedCount = safeRows.filter((r) => String(r?.orderstatus || '').toLowerCase() === 'pending').length;
const byRider = new Map();
safeRows.filter((r) => isOpen(r) && r.userid).forEach((r) => {
const key = String(r.userid);
if (!byRider.has(key)) byRider.set(key, { riderId: r.userid, name: r.ridername || `Rider ${r.userid}`, rows: [] });
byRider.get(key).rows.push(r);
});
const saturated = [...byRider.values()]
.filter((entry) => entry.rows.length >= activeCap)
.sort((a, b) => b.rows.length - a.rows.length);
if (!saturated.length || unassignedCount === 0) return [];
return [
{
id: 'rider_saturation',
skillId: 'skill_fleet_balancer',
severity: 'watch',
title: `${saturated.length} rider${saturated.length === 1 ? ' is' : 's are'} at capacity (≥${activeCap} jobs) while orders queue`,
why: `Riders carrying ${activeCap}+ in-flight jobs are capped and will not receive newly queued orders.`,
count: saturated.length,
rows: saturated.flatMap((entry) => entry.rows),
detail: saturated.slice(0, 8).map((entry) => `${entry.name} — ${entry.rows.length} concurrent orders`)
}
];
}
};

View File

@@ -0,0 +1,93 @@
// ==============================|| Skill: High-Value COD Escort ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HighValueCodSkill = {
id: 'skill_high_value_cod',
name: 'High-Value Cash Guardian',
description: 'Audits large Cash-on-Delivery consignments to prevent loss, fraud, or unverified deliveries.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no payment amount or payment mode: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no payment amount or payment mode: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
codRiskThresholdAmount: {
label: 'High-Value COD Threshold (₹)',
description: 'Cash-on-Delivery amount above which an order is classified as high-risk cash asset.',
value: 3000,
min: 1000,
max: 20000,
step: 500,
unit: '₹'
}
},
instructions: `
- High-value COD consignments must require OTP verification at the doorstep and verified senior rider assignment.
`,
tools: [
{
name: 'enforce_otp_verification',
description: 'Flags an order as strictly requiring consignee OTP delivery confirmation before handover.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Target booking ID' }
},
required: ['bookingId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'require_otp',
bookingId: args.bookingId,
blastRadius: `Enables mandatory OTP confirmation on mobile app for parcel #${args.bookingId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minAmount = currentThresholds.codRiskThresholdAmount ?? 3000;
const safeRows = Array.isArray(rows) ? rows : [];
const highValueCod = safeRows.filter((r) => {
if (!isOpen(r)) return false;
const isCod = String(r.paymenttype || r.payment_mode || '').toLowerCase().includes('cod') ||
String(r.paymentstatus || '').toLowerCase() === 'pending';
const amount = Number(r.orderamount || r.totalamount || r.price || 0);
return isCod && amount >= minAmount;
});
if (!highValueCod.length) return [];
return [
{
id: 'high_value_cod_alert',
skillId: 'skill_high_value_cod',
severity: 'watch',
title: `${highValueCod.length} high-value COD consignment${highValueCod.length === 1 ? '' : 's'} (≥ ₹${minAmount.toLocaleString()}) active`,
why: `Large cash collection in transit requires mandatory OTP handover verification.`,
count: highValueCod.length,
rows: highValueCod,
detail: highValueCod.slice(0, 8).map((r) => {
const amt = Number(r.orderamount || r.totalamount || r.price || 0);
return `${ref(r)} — ₹${amt.toLocaleString()} (${r.orderstatus || 'active'})`;
}),
proposal: {
label: 'Verify OTP requirements',
tool: 'enforce_otp_verification',
scope: highValueCod,
blastRadius: 'Sets strict OTP delivery guard on high-value cash deliveries.'
}
}
];
}
};

View File

@@ -0,0 +1,105 @@
// ==============================|| Skill: Hub Congestion Agent ||============================== //
//
// Detects parcels that have been sitting at a hub for too long without
// being picked up by a rider. These represent blocked capacity at the hub
// and direct SLA risk for the customer. This is critical for hub operations
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const HUB_STATUSES = new Set(['pending', 'accepted']); // at hub, not yet picked
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HubCongestionSkill = {
id: 'skill_hub_congestion',
name: 'Hub Congestion Agent',
description: 'Detects parcels sitting at a hub without rider pickup beyond the dwell threshold. Dispatches nearest idle rider.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
hubDwellMinutes: {
label: 'Hub Dwell Threshold',
description: 'Minutes a parcel can sit at hub without a rider pickup before raising a congestion alert.',
value: 45,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- Parcels sitting at a hub beyond the dwell threshold block capacity for the next batch.
- Identify the nearest idle rider in the same zone and dispatch immediately.
- Prefer riders with the fewest active orders to avoid overloading.
`,
tools: [
{
name: 'dispatch_hub_idle_parcels',
description: 'Proposes dispatching a nearby idle rider to collect stalled hub parcels.',
parameters: {
type: 'object',
properties: {
hubId: { type: 'string', description: 'Hub identifier where parcels are stalled' },
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs to dispatch' },
milerId: { type: 'number', description: 'Target idle rider user ID' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'dispatch_hub_parcels',
hubId: args.hubId || 'unknown',
bookingIds: args.bookingIds,
milerId: args.milerId,
blastRadius: `Dispatches rider to collect ${args.bookingIds?.length || 'stalled'} parcel(s) from hub. Updates parcel status to In-Transit.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const dwellLimit = currentThresholds.hubDwellMinutes ?? 45;
const findings = [];
const stalled = rows
.filter((r) => HUB_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, dwellMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.dwellMin !== null && m.dwellMin >= dwellLimit)
.sort((a, b) => b.dwellMin - a.dwellMin);
if (stalled.length) {
findings.push({
id: 'hub_parcel_stalled',
skillId: 'skill_hub_congestion',
severity: 'critical',
title: `${stalled.length} parcel${stalled.length === 1 ? '' : 's'} stalled at hub for >${dwellLimit}m`,
why: 'Parcel has been at the hub beyond the acceptable dwell time with no rider assigned for pickup.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — at hub for ${m.dwellMin} min`),
proposal: {
label: 'Dispatch idle rider to hub',
tool: 'dispatch_hub_idle_parcels',
scope: stalled.map((m) => m.row),
blastRadius: 'Assigns nearest idle zone rider to collect and dispatch all stalled parcels.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,133 @@
// ==============================|| Skill: Late Dispatch Agent ||============================== //
//
// Flags orders that have been accepted/created but not yet dispatched to a rider
// within the acceptable dispatch window during business hours.
// Late dispatch at creation cascades into late delivery at the door.
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const PENDING_DISPATCH_STATUSES = new Set(['pending']);
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const LateDispatchSkill = {
id: 'skill_late_dispatch',
name: 'Late Dispatch Agent',
description: 'Flags orders accepted but sitting in queue without dispatch for too long. Triggers auto-assignment to prevent cascading delays.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
lateDispatchMinutes: {
label: 'Dispatch Deadline',
description: 'Minutes after order creation before flagging as late dispatch.',
value: 30,
min: 10,
max: 90,
step: 5,
unit: 'min'
},
criticalDispatchMinutes: {
label: 'Critical Dispatch Deadline',
description: 'Minutes after order creation before escalating to critical alert.',
value: 60,
min: 30,
max: 180,
step: 10,
unit: 'min'
}
},
instructions: `
- Orders waiting for dispatch beyond the threshold accumulate delay that can't be recovered at the doorstep.
- Auto-assign to the nearest available rider immediately.
- Prioritize orders with the earliest SLA deadlines when multiple are pending.
`,
tools: [
{
name: 'trigger_auto_dispatch',
description: 'Proposes auto-assigning late-pending orders to available riders in zone.',
parameters: {
type: 'object',
properties: {
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs requiring immediate dispatch' },
reason: { type: 'string', description: 'Dispatch urgency reason' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'auto_dispatch',
bookingIds: args.bookingIds,
reason: args.reason || 'Late dispatch threshold exceeded',
blastRadius: `Auto-assigns ${args.bookingIds?.length || 'late'} order(s) to nearest available zone riders. Rider notifications will be sent.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const lateThreshold = currentThresholds.lateDispatchMinutes ?? 30;
const criticalThreshold = currentThresholds.criticalDispatchMinutes ?? 60;
const findings = [];
const allPending = rows
.filter((r) => PENDING_DISPATCH_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, waitMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.waitMin !== null && m.waitMin >= lateThreshold)
.sort((a, b) => b.waitMin - a.waitMin);
const critical = allPending.filter((m) => m.waitMin >= criticalThreshold);
const warning = allPending.filter((m) => m.waitMin >= lateThreshold && m.waitMin < criticalThreshold);
if (critical.length) {
findings.push({
id: 'late_dispatch_critical',
skillId: 'skill_late_dispatch',
severity: 'critical',
title: `${critical.length} order${critical.length === 1 ? '' : 's'} critically late for dispatch (>${criticalThreshold}m)`,
why: `Order has been sitting in queue for over ${criticalThreshold} minutes with no rider assigned. Delivery SLA is at severe risk.`,
count: critical.length,
rows: critical.map((m) => m.row),
detail: critical.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min for dispatch`),
proposal: {
label: 'Auto-dispatch to available riders',
tool: 'trigger_auto_dispatch',
scope: critical.map((m) => m.row),
blastRadius: 'Immediately assigns nearest available riders. Overrides manual queue priority.'
}
});
}
if (warning.length) {
findings.push({
id: 'late_dispatch_warning',
skillId: 'skill_late_dispatch',
severity: 'warning',
title: `${warning.length} order${warning.length === 1 ? '' : 's'} approaching dispatch deadline (>${lateThreshold}m)`,
why: `Order has been waiting ${lateThreshold}+ minutes for rider assignment. Act now to prevent SLA breach.`,
count: warning.length,
rows: warning.map((m) => m.row),
detail: warning.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min`),
proposal: {
label: 'Dispatch now',
tool: 'trigger_auto_dispatch',
scope: warning.map((m) => m.row),
blastRadius: 'Assigns available riders to pending orders based on zone proximity.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,91 @@
// ==============================|| Skill: Rider Battery & Device Safety ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const RiderBatterySafetySkill = {
id: 'skill_rider_battery_safety',
name: 'Rider Device & SOS Safety',
description: 'Monitors rider device telemetry to prevent unreachability due to low battery during active deliveries.',
category: 'rider_safety',
icon: 'BatteryWarning',
// OFF by default (2026-09-29): the rows this rule reads carry no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows',
defaultEnabled: false,
thresholds: {
criticalBatteryPercent: {
label: 'Critical Battery Level (%)',
description: 'Threshold below which rider device is considered at risk of shutdown.',
value: 15,
min: 5,
max: 30,
step: 5,
unit: '%'
}
},
instructions: `
- When a rider has critical battery while carrying in-transit orders, advise charging or offloading remaining pending pickups.
`,
tools: [
{
name: 'alert_low_battery_rider',
description: 'Notifies rider to connect portable charger or report to nearest hub.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Target miler user ID' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'battery_alert',
milerId: args.milerId,
blastRadius: `Sends device power warning notification to Rider #${args.milerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minBattery = currentThresholds.criticalBatteryPercent ?? 15;
const safeRows = Array.isArray(rows) ? rows : [];
const atRisk = safeRows.filter((r) => {
if (!isOpen(r) || !r.userid) return false;
const battery = Number(r.battery_percentage || r.battery_level || r.battery || 100);
return battery > 0 && battery <= minBattery;
});
if (!atRisk.length) return [];
return [
{
id: 'rider_critical_battery',
skillId: 'skill_rider_battery_safety',
severity: 'warning',
title: `${atRisk.length} active delivery under low rider device battery (≤ ${minBattery}%)`,
why: `Rider phone is nearing shutdown, risking lost GPS telemetry and customer communication failure.`,
count: atRisk.length,
rows: atRisk,
detail: atRisk.slice(0, 8).map((r) => {
const bat = r.battery_percentage || r.battery_level || r.battery || 'Low';
return `${ref(r)} — ${r.ridername || 'Rider'} (${bat}% battery)`;
}),
proposal: {
label: 'Send power alert',
tool: 'alert_low_battery_rider',
scope: atRisk,
blastRadius: 'Sends charging prompt to rider.'
}
}
];
}
};

View File

@@ -0,0 +1,157 @@
// ==============================|| Skill: SLA Breach Guardian ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const IN_FLIGHT_STATUSES = new Set(['picked', 'active']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const SlaGuardianSkill = {
id: 'skill_sla_guardian',
name: 'SLA Breach Guardian',
description: 'Monitors promised customer delivery ETAs and flags both breached and imminent SLA violations.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
slaRiskWindowMin: {
label: 'At-Risk Warning Window',
description: 'Minutes before promised delivery window to flag an un-picked parcel as at-risk.',
value: 45,
min: 15,
max: 90,
step: 5,
unit: 'min'
},
unassignedAgingMin: {
label: 'Unassigned Aging Threshold',
description: 'Minutes an order can remain pending with no assigned rider before raising an alarm.',
value: 60,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- When orders exceed promised delivery ETA or are within the risk window without being in flight, prioritize immediate reassignment.
- Select nearby available riders with high historical on-time fulfillment rates.
`,
tools: [
{
name: 'reassign_sla_critical_order',
description: 'Proposes transferring an SLA-at-risk order to an optimal nearby rider.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Booking ID to reassign' },
targetMilerId: { type: 'number', description: 'Target miler user ID' },
reason: { type: 'string', description: 'Justification' }
},
required: ['bookingId', 'targetMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'reassign_miler',
bookingId: args.bookingId,
milerId: args.targetMilerId,
reason: args.reason || 'SLA risk mitigation',
blastRadius: `Reassigns parcel #${args.bookingId} and updates customer ETA tracking.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const riskWindow = currentThresholds.slaRiskWindowMin ?? 45;
const agingWindow = currentThresholds.unassignedAgingMin ?? 60;
const findings = [];
// 1. Breached SLA
const breached = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime)
.map((r) => ({ row: r, overdueMin: minutesBetween(r.expecteddeliverytime, now) }))
.filter((m) => m.overdueMin !== null && m.overdueMin > 0)
.sort((a, b) => b.overdueMin - a.overdueMin);
if (breached.length) {
findings.push({
id: 'sla_breached',
skillId: 'skill_sla_guardian',
severity: 'critical',
title: `${breached.length} ${breached.length === 1 ? 'parcel is' : 'parcels are'} past promised delivery time`,
why: 'Estimated delivery time on service option has elapsed and parcel is not yet delivered.',
count: breached.length,
rows: breached.map((m) => m.row),
detail: breached.slice(0, 8).map((m) => `${ref(m.row)} — ${m.overdueMin} min overdue, status: ${m.row.orderstatus}`),
proposal: {
label: 'Notify assigned riders',
tool: 'notifyRider',
scope: breached.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an urgent priority notification to the assigned rider.'
}
});
}
// 2. At-Risk SLA
const atRisk = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime && !IN_FLIGHT_STATUSES.has(String(r.orderstatus).toLowerCase()))
.map((r) => ({ row: r, dueInMin: minutesBetween(now, r.expecteddeliverytime) }))
.filter((m) => m.dueInMin !== null && m.dueInMin > 0 && m.dueInMin <= riskWindow)
.sort((a, b) => a.dueInMin - b.dueInMin);
if (atRisk.length) {
findings.push({
id: 'sla_at_risk',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${atRisk.length} parcel${atRisk.length === 1 ? '' : 's'} due within ${riskWindow}m and not yet moving`,
why: 'Inside the delivery promise window but parcel pickup has not begun.',
count: atRisk.length,
rows: atRisk.map((m) => m.row),
detail: atRisk.slice(0, 8).map((m) => `${ref(m.row)} — due in ${m.dueInMin} min, status: ${m.row.orderstatus}`)
});
}
// 3. Unassigned Aging
const aging = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'pending')
.map((r) => ({ row: r, ageMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.ageMin !== null && m.ageMin >= agingWindow)
.sort((a, b) => b.ageMin - a.ageMin);
if (aging.length) {
findings.push({
id: 'unassigned_aging',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${aging.length} booking${aging.length === 1 ? '' : 's'} unassigned for > ${agingWindow}m`,
why: 'Order has been sitting in queue with no assigned miler.',
count: aging.length,
rows: aging.map((m) => m.row),
detail: aging.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.ageMin} min`),
proposal: {
label: 'Auto-assign available rider',
tool: 'assignMiler',
scope: aging.map((m) => m.row),
blastRadius: 'Initiates dispatch matching across idle zone riders.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,10 @@
export { SkillRegistry, DEFAULT_SKILLS } from './SkillRegistry';
export { useSkillRegistrySync } from './useSkillRegistrySync';
export { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
export { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
export { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
export { HighValueCodSkill } from './definitions/HighValueCodSkill';
export { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
export { HubCongestionSkill } from './definitions/HubCongestionSkill';
export { LateDispatchSkill } from './definitions/LateDispatchSkill';
export { CashExposureSkill } from './definitions/CashExposureSkill';

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useAiSkills } from '@/lib/doormileHooks';
import { SkillRegistry } from './SkillRegistry';
/**
* Keeps the in-memory SkillRegistry in step with the backend agent registry.
*
* Mounted once, in the console shell, so the Exceptions banner and the chat's
* "what needs attention" briefing both run on the same, tuned settings. A
* change saved in Settings → Skills & Tools invalidates the registry query,
* this refetches, and every subscriber re-evaluates.
*
* `enabled` is false for partner-tenant logins: the registry is Doormile-staff
* only (the backend answers 403), so they run on code defaults, which the
* banner states.
*/
export function useSkillRegistrySync(enabled = true) {
const { data, isError } = useAiSkills({ enabled, retry: false });
useEffect(() => {
if (data) SkillRegistry.applyRegistryConfig(data);
}, [data]);
useEffect(() => {
if (isError || !enabled) SkillRegistry.clearRegistryConfig();
}, [isError, enabled]);
}