update on the agents and registry side
This commit is contained in:
92
src/lib/assistant/agent/AgentFactory.js
Normal file
92
src/lib/assistant/agent/AgentFactory.js
Normal 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());
|
||||
}
|
||||
}
|
||||
153
src/lib/assistant/agent/actions.js
Normal file
153
src/lib/assistant/agent/actions.js
Normal 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);
|
||||
};
|
||||
112
src/lib/assistant/agent/briefing.js
Normal file
112
src/lib/assistant/agent/briefing.js
Normal 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' }
|
||||
};
|
||||
};
|
||||
53
src/lib/assistant/agent/normalise.js
Normal file
53
src/lib/assistant/agent/normalise.js
Normal 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) : []);
|
||||
44
src/lib/assistant/agent/signals.js
Normal file
44
src/lib/assistant/agent/signals.js
Normal 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.';
|
||||
Reference in New Issue
Block a user