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,92 @@
// ==============================|| Doormile AI — Autonomous Agent Factory ||============================== //
import { SkillRegistry } from '../skills/SkillRegistry';
import { wallClockNow } from './signals';
const SEVERITY_RANK = { critical: 0, warning: 1, watch: 2 };
export class AgentFactory {
/**
* Compiles an Agent configuration from a collection of skills.
*/
static createAgent(skills = SkillRegistry.getActiveSkills()) {
const activeSkills = Array.isArray(skills) ? skills : [];
// 1. Synthesize System Prompt
const instructions = activeSkills
.map((s) => `### ${s.name} (${s.category})\n${(s.instructions || '').trim()}`)
.join('\n\n');
const systemPrompt = `
You are the Doormile Autonomous Logistics Fleet Orchestrator.
You operate with the following active operational skill modules:
${instructions || 'Standard logistics routing and telemetry diagnostics.'}
Guiding Invariant:
Read-only queries execute instantly. Mutating actions (reassignments, pings, OTP enforcement) MUST return human-in-the-loop proposals with clear blast radius.
`.trim();
// 2. Aggregate Tools from active skills
const tools = activeSkills.flatMap((s) => s.tools || []);
const toolMap = new Map();
tools.forEach((t) => toolMap.set(t.name, t));
// 3. Telemetry Evaluation Engine
const evaluateTelemetry = (rows = [], now = wallClockNow()) => {
const allFindings = [];
for (const skill of activeSkills) {
if (typeof skill.evaluate === 'function') {
// Extract current threshold values
const rawThresholds = {};
if (skill.effectiveThresholds) {
Object.entries(skill.effectiveThresholds).forEach(([k, v]) => {
rawThresholds[k] = v.currentValue;
});
} else if (skill.thresholds) {
Object.entries(skill.thresholds).forEach(([k, v]) => {
rawThresholds[k] = v.value;
});
}
const findings = skill.evaluate(rows, now, rawThresholds);
if (Array.isArray(findings)) {
allFindings.push(...findings);
}
}
}
// Sort by severity (critical > warning > watch), then by affected row count descending
return allFindings.sort(
(a, b) =>
(SEVERITY_RANK[a.severity] ?? 99) - (SEVERITY_RANK[b.severity] ?? 99) ||
(b.count || 0) - (a.count || 0)
);
};
return {
name: 'Doormile Fleet Orchestrator',
version: '2.0.0',
activeSkillCount: activeSkills.length,
skills: activeSkills,
tools,
toolMap,
systemPrompt,
evaluateTelemetry,
executeSkillTool: async (toolName, args) => {
const tool = toolMap.get(toolName);
if (!tool || typeof tool.handler !== 'function') {
return { success: false, error: `Skill tool "${toolName}" not found or has no handler.` };
}
return tool.handler(args);
}
};
}
/**
* Helper to build the default active agent runtime based on current SkillRegistry state.
*/
static synthesizeDefaultAgent() {
return AgentFactory.createAgent(SkillRegistry.getActiveSkills());
}
}

View File

@@ -0,0 +1,153 @@
// ==============================|| Doormile AI — proposal executors ||============================== //
//
// The layer that turns a finding's proposal into a real API call.
//
// It exists as its own module because the skills deliberately have no API
// access at all — not one of them imports an endpoint, and their tool handlers
// only build proposal objects. That separation is worth keeping: a skill stays
// a pure function of rows and thresholds, which is why all eight are testable
// without a network. Execution is the part with consequences, so it lives in
// one place where the failure modes can be handled once.
//
// Two rules, both learned the hard way in assignActions.js:
//
// • Never report an action that did not happen. A card that says "rider
// notified" when the push returned 400 is worse than one that says nothing:
// a dispatcher who believes a rider was pinged does not follow up.
// • A partial success is a partial success. Notifying six riders out of eight
// is reported as six out of eight, with the two failures named.
import { getMilers, buildMilerLookup, notifyRider } from '@/api/doormile';
/** A stable, human-facing reference for a row. */
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
/**
* Rider pushes key off milerprofileid; findings carry mileruserid.
*
* These are two different small integers on the same record, and using the
* wrong one fails silently in both directions — the notify 404s, or a different
* rider is paged. buildMilerLookup is the same bridge the Orders page uses;
* this must never grow a second one.
*/
const profileIdFor = (row, lookup) => lookup.byUserId.get(String(row?.userid))?.milerprofileid;
// ---- notifyRider ------------------------------------------------------------
const executeNotifyRider = async (scope, message) => {
const rows = (scope || []).filter((r) => r?.userid);
if (!rows.length) {
return { ok: false, message: 'None of these parcels has an assigned rider to notify.', sourceCalls: [] };
}
// One fetch for the whole batch. Per row would be an identical request each
// time, and the lookup is the same for all of them.
const milers = await getMilers();
const lookup = buildMilerLookup(milers);
const sourceCalls = [];
const notified = [];
const unreachable = [];
for (const row of rows) {
const profileId = profileIdFor(row, lookup);
// No profile id means no push is possible. Recorded rather than skipped,
// so the operator is not left assuming a phone buzzed.
if (!profileId) {
unreachable.push(`${ref(row)} (${row.ridername || 'rider'}: no device registered)`);
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(profileId, 'Doormile Ops', message);
notified.push(ref(row));
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'complete',
stats: `${ref(row)} → ${row.ridername || 'rider'}`
});
} catch (err) {
unreachable.push(`${ref(row)} (${err?.message || 'push failed'})`);
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'error',
errorMessage: err?.message || 'notification failed'
});
}
}
const parts = [];
if (notified.length) parts.push(`Notified ${notified.length} rider${notified.length === 1 ? '' : 's'}.`);
if (unreachable.length) parts.push(`Could not reach ${unreachable.length}: ${unreachable.join('; ')}.`);
return {
ok: notified.length > 0,
message: parts.join(' ') || 'Nothing was sent.',
notified,
unreachable,
sourceCalls
};
};
// ---- assignMiler: deliberately NOT an executor --------------------------------
//
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. Shipping it would have
// put a button on the Exceptions banner that could never succeed.
//
// Findings that propose `assignMiler` therefore render "Review only". The
// admin route that could back it, POST /admin/bookings/:id/assign-miler, needs
// a chosen rider per booking, which a finding does not pick. Wiring this is a
// backend decision (an admin batch-assign route), not a console one.
// ---- registry ---------------------------------------------------------------
/**
* Keyed on the `tool` a finding's proposal actually carries.
*
* Deliberately NOT the skills' declared tool names (ping_stalled_rider,
* reassign_sla_critical_order, …). Those belong to a separate, currently unused
* layer whose handlers only build proposals; the UI renders `proposal.tool`,
* and that is what an operator's click has to resolve.
*
* Verbs with no entry here have no endpoint behind them yet. That is not an
* error — the card renders them disabled and says "Review only" rather than
* pretending.
*/
const EXECUTORS = {
notifyRider: (finding) =>
executeNotifyRider(
finding?.proposal?.scope,
finding?.severity === 'critical'
? 'Urgent: this delivery needs attention now. Please check the app.'
: 'Please check this delivery in the app when you can.'
)
// assignMiler: none — see the note above. Its findings render "Review only".
};
/** Whether a finding's proposal can actually be carried out. */
export const canExecuteProposal = (finding) => Boolean(EXECUTORS[finding?.proposal?.tool]);
/** Every verb that currently has a real executor behind it. */
export const executableTools = () => Object.keys(EXECUTORS);
/**
* Runs a finding's proposal.
*
* Throws when there is no executor rather than resolving quietly, so a caller
* cannot mistake "nothing happened" for success. The UI is expected to have
* checked canExecuteProposal first and disabled the control.
*/
export const executeProposal = async (finding) => {
const run = EXECUTORS[finding?.proposal?.tool];
if (!run) {
throw new Error(`No executor for "${finding?.proposal?.tool}" — this proposal is review-only.`);
}
return run(finding);
};

View File

@@ -0,0 +1,112 @@
// ==============================|| Doormile AI — the ops briefing ||============================== //
//
// Turns the findings from signals.js into the shape the assistant panel already
// renders: a headline, a detail body, and a metric. Pure — it takes rows and an
// instant and returns an object, so the whole briefing can be tested without a
// network, a clock, or React.
//
// This is the agent's "explain" step. Every line it produces is traceable to a
// rule in signals.js and to the rows that rule matched, because an operator who
// cannot see why the agent believes something will not act on it — and an
// unexplained instruction from a bot is exactly the thing CLAUDE.md §3 refuses
// to ship.
import { AgentFactory } from './AgentFactory';
import { ALL_CLEAR, wallClockNow } from './signals';
import { toAgentRows } from './normalise';
const SEVERITY_LABEL = {
critical: 'Needs attention now',
warning: 'Worth looking at',
watch: 'Keep an eye on'
};
/**
* A truncated scan is a floor, not a total.
*
* `/admin/bookings` caps pagesize, so a scan drains pages up to a budget and
* reports whether it ran out. Every count built on a truncated scan says "at
* least", because the alternative — stating a total that is quietly a subset —
* is the exact failure the assistant's verifiability contract exists to
* prevent. A missed breach is worse than an approximate one, so the briefing
* still runs; it just stops claiming completeness.
*/
export const countPrefix = (truncated) => (truncated ? 'at least ' : '');
/** One finding rendered as a block of lines. */
const renderFinding = (finding, truncated) => {
const lines = [];
lines.push(`${SEVERITY_LABEL[finding.severity]} — ${finding.title}`);
lines.push(finding.why);
if (finding.detail?.length) {
lines.push(...finding.detail.map((d) => ` • ${d}`));
const shown = finding.detail.length;
if (finding.count > shown) {
lines.push(` • …and ${countPrefix(truncated)}${finding.count - shown} more`);
}
}
if (finding.proposal) {
lines.push(` → ${finding.proposal.label} (${finding.proposal.blastRadius})`);
}
return lines.join('\n');
};
/**
* Builds the full briefing.
*
* `scan` is the result of the assistant's own booking scan — `{ rows,
* truncated }` — so the briefing inherits the same data the rest of the
* catalog answers from and cannot disagree with it.
*/
export const buildBriefing = (scan, now = wallClockNow()) => {
// Raw `/admin/bookings` rows in, agent rows out. Without this the rules read
// undefined on every field and the briefing reports an all-clear board.
const rows = toAgentRows(scan?.rows);
const truncated = Boolean(scan?.truncated);
// The SAME engine the Exceptions banner runs, deliberately.
//
// This used to call a private set of rules in signals.js that duplicated five
// of the skills and hardcoded their thresholds. The two drifted immediately —
// signals.js flagged a stalled rider at 25 minutes while DoorstepStallSkill
// used 20 — so the chat panel and the banner disagreed about the same parcel,
// and a threshold moved in Agent Studio changed one surface but not the other.
//
// Going through the registry means there is one definition of every rule, one
// place thresholds live, and the chat panel sees every enabled skill rather
// than the five somebody happened to reimplement here.
const findings = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(rows, now);
if (!findings.length) {
return {
headline: ALL_CLEAR,
detail: truncated
? 'Note: the booking scan hit its page budget, so this covers the most recent orders rather than every one.'
: `Checked ${rows.length} open and recent bookings.`,
findings,
metric: { value: 0, label: 'issues found' }
};
}
const critical = findings.filter((f) => f.severity === 'critical');
const affected = new Set(findings.flatMap((f) => f.rows.map((r) => r.bookingid))).size;
const headline = critical.length
? `${critical.length} thing${critical.length === 1 ? '' : 's'} need${critical.length === 1 ? 's' : ''} attention now, across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`
: `Nothing critical, but ${findings.length} thing${findings.length === 1 ? '' : 's'} worth looking at across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`;
const body = findings.map((f) => renderFinding(f, truncated)).join('\n\n');
const footer = truncated
? '\n\nThe booking scan hit its page budget, so these counts are a floor — there may be more.'
: '';
return {
headline,
detail: `${body}${footer}`,
findings,
metric: { value: findings.length, label: findings.length === 1 ? 'issue found' : 'issues found' }
};
};

View File

@@ -0,0 +1,53 @@
// ==============================|| Doormile AI — scan row adapter ||============================== //
//
// The assistant's booking scan returns RAW rows straight off `/admin/bookings`
// — `status`, `createdat`, `assignedmileruserid`, `serviceoptions[0]` — not the
// normalised delivery rows the Deliveries page renders. The two shapes share
// almost no field names, so the agent's rules cannot read a scan row directly.
//
// This adapter is the seam. It exists as its own file because getting it wrong
// fails silently in the worst possible way: every rule reads `undefined`, every
// rule matches nothing, and the agent cheerfully reports "nothing needs
// attention" on a board that is on fire. That is precisely the failure mode
// CLAUDE.md §3 exists to prevent, and it is invisible without a test.
//
// The status derivation deliberately goes through the console's own
// `mapBookingStatusToDeliveryStatus` rather than a local copy — the same
// function the Deliveries page's table calls. That is the house rule (never
// hand-roll a second data path) and it is also what guarantees the agent and
// the screen can never disagree about what state a parcel is in.
import { mapBookingStatusToDeliveryStatus } from '@/api/doormile/queries';
/** The rider's arrival stamp, which the API has spelled several ways. */
const reachedAtOf = (b) => b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
/** Rider display name, from whichever join carried it. */
const riderNameOf = (b) =>
b.milername || b.ridername || b.assignedmilername || b.miler?.name || undefined;
/**
* One raw booking row → the shape signals.js reads.
*
* Every field is derived, never invented: a raw row that carries no service
* option yields `expecteddeliverytime: undefined`, and the SLA rules skip it
* rather than treating a missing promise as a kept or broken one.
*/
export const toAgentRow = (b) => ({
bookingid: b.bookingid ?? b.id,
orderid: b.bookingno || (b.bookingid ? `#${b.bookingid}` : '—'),
// Booking status, consignment status and reachedat resolved by the console's
// own mapper — including the Converted_To_Consignment handoff, where the
// lifecycle moves onto the consignment and the booking's status freezes.
orderstatus: mapBookingStatusToDeliveryStatus(b),
orderdate: b.createdat || b.orderdate || b.updatedat,
expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat,
reachedat: reachedAtOf(b),
userid: b.assignedmileruserid ?? b.mileruserid,
ridername: riderNameOf(b)
});
/** A whole scan's rows, adapted. Non-array input yields an empty list. */
export const toAgentRows = (rows) => (Array.isArray(rows) ? rows.map(toAgentRow) : []);

View File

@@ -0,0 +1,44 @@
// ==============================|| Doormile AI — shared agent primitives ||============================== //
//
// What is left after the rules moved out.
//
// This file used to hold its own copy of five detection rules — breached SLA,
// at-risk SLA, aging unassigned work, doorstep stalls, rider saturation — each
// with a hardcoded threshold in a local THRESHOLDS object. Every one of those
// rules already existed as a skill in skills/definitions/, with the same
// finding id and a threshold an operator can tune in Agent Studio.
//
// Two implementations of one rule is a bug with a delay fuse, and this one had
// already fired: signals.js flagged a doorstep stall at 25 minutes while
// DoorstepStallSkill used 20, so the chat panel and the Exceptions banner
// disagreed about the same rider, out of the box, before anyone touched a
// slider. Worse, the hardcoded copy could not see the registry at all — tuning
// a threshold moved the banner and left the chat panel where it was.
//
// The rules now live in exactly one place: skills/definitions/. Everything that
// needs findings goes through AgentFactory, which reads the registry, so there
// is one definition per rule and one place a threshold can be changed.
//
// What remains here is the small shared vocabulary that is genuinely common to
// every consumer and belongs to none of them.
import dayjs from 'dayjs';
/**
* "Now", in the same form every Doormile timestamp is stored in.
*
* NOT `new Date().toISOString()`. That returns UTC, and parseDoormileTimestamp
* deliberately strips any zone marker and reads the remaining digits as IST
* wall-clock — so an ISO string would hand the rules a clock running 5h30m
* slow, and every parcel less than five and a half hours overdue would look
* like it was still in the future. The agent would report an all-clear board
* through most of a working day.
*
* This was a live bug. It passed every unit test, because the tests all passed
* `now` explicitly and only the default was wrong.
*/
export const wallClockNow = () => dayjs().format('YYYY-MM-DD HH:mm:ss');
/** Shown when every enabled skill returns no findings. */
export const ALL_CLEAR =
'Nothing needs attention right now — no breached promises, no stalled riders, and no aging unassigned work.';