updates on the hook and endpoints for the ai agents

This commit is contained in:
2026-09-30 15:08:33 +05:30
parent ede1731fa5
commit d33fff01d3
5 changed files with 180 additions and 38 deletions

View File

@@ -826,6 +826,16 @@ export const updateAiAgent = async (id, patch) => {
* false when the backend is not subscribed to AI_engine telemetry. * false when the backend is not subscribed to AI_engine telemetry.
* @param {number} days * @param {number} days
*/ */
/**
* What is actually wired, for the Skills & Tools banner: whether AI_engine is
* reading the registry (it polls ~every 30 s; the backend records each read),
* telemetry, live agents, and whether the Test tab has a model.
*/
export const getAiStatus = async () => {
const response = await doormileAxios.get('/admin/ai/status');
return response.data.data;
};
export const getAiInsights = async (days = 7) => { export const getAiInsights = async (days = 7) => {
const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`); const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`);
return response.data.data; return response.data.data;

View File

@@ -865,6 +865,18 @@ export const useUpdateAiAgent = () =>
}); });
// Read-only, and it changes as agents work: refetched every minute while open. // Read-only, and it changes as agents work: refetched every minute while open.
// The banner's source of truth; re-checked every 30 s so it turns green on its
// own once AI_engine starts reading the registry.
export const useAiStatus = (options) =>
useQuery({
queryKey: [...KEYS.aiRegistry, 'status'],
queryFn: api.getAiStatus,
staleTime: 15_000,
refetchInterval: 30_000,
retry: false,
...options,
});
export const useAiInsights = (days = 7, options) => export const useAiInsights = (days = 7, options) =>
useQuery({ useQuery({
queryKey: [...KEYS.aiRegistry, 'insights', days], queryKey: [...KEYS.aiRegistry, 'insights', days],

View File

@@ -5,7 +5,6 @@ import {
Wrench, Wrench,
Bot, Bot,
BarChart3, BarChart3,
AlertTriangle,
Info, Info,
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
@@ -28,6 +27,7 @@ import SkillDetailsDrawer from './SkillDetailsDrawer';
import CreateSkillModal from './CreateSkillModal'; import CreateSkillModal from './CreateSkillModal';
import ToolRegistryView from './ToolRegistryView'; import ToolRegistryView from './ToolRegistryView';
import AgentPlayground from './AgentPlayground'; import AgentPlayground from './AgentPlayground';
import WiringStatusBanner from './WiringStatusBanner';
import { AgentConfigView, AgentInsightsView } from './AgentConfigAndInsights'; import { AgentConfigView, AgentInsightsView } from './AgentConfigAndInsights';
const TOP_NAV_TABS = [ const TOP_NAV_TABS = [
@@ -122,22 +122,9 @@ export default function AgentStudio() {
return ( return (
<div className="space-y-5 pb-10"> <div className="space-y-5 pb-10">
{/* Said on screen: what this page changes today, and what it does not. */} {/* Said on screen: what these settings actually drive right now, from
<div GET /admin/ai/status — not a fixed note. */}
role="note" <WiringStatusBanner />
className="flex items-start gap-3 rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm"
>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-500" />
<p className="leading-relaxed">
<span className="font-bold text-amber-800">Half wired.</span>{' '}
Changes here are saved to the Doormile agent registry and every change is audited. The Console Ops Agent&apos;s skills — the Exceptions early warnings and the
chat&apos;s &ldquo;what needs attention&rdquo; — run on these settings now. AI_engine&apos;s Dispatch,
Exception, Express Dispatch and Customer agents pick up changes within about 30 seconds once its
registry-reading build is deployed; until then, and whenever the registry cannot be reached, they run on
their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real AI model
calls (reads redacted, writes only proposed) once the server has a model API key; until then it says so.
</p>
</div>
{!canEdit && ( {!canEdit && (
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-600"> <div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-600">

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react';
import { useAiStatus } from '@/lib/doormileHooks';
/**
* What this page's settings actually drive, from GET /admin/ai/status — not a
* fixed note. It used to say "Half wired" forever, including after AI_engine
* and the backend were deployed, because nothing checked.
*
* Green only when AI_engine has read the registry recently (the backend stamps
* every poll) and the Test tab has a model. Re-checks every 30 seconds.
*/
function ago(iso) {
if (!iso) return '';
const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.round(s / 60)} min ago`;
return new Date(iso).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' });
}
/** The banner's lines, from a status payload. Pure, so it is tested directly. */
export function wiringLines(status) {
const engine = status?.engine || {};
const playground = status?.playground || {};
return [
{ ok: true, text: 'Changes are saved to the Doormile agent registry, and every change is audited.' },
{
ok: true,
text: 'The Console Ops Agent’s skills (Exceptions early warnings, the chat’s “what needs attention”) run on these settings.',
},
engine.readingsettings
? {
ok: true,
text: `AI_engine is following these settings (last read ${ago(engine.lastreadat)}); changes reach its Dispatch, Exception, Express Dispatch and Customer agents within about 30 seconds.`,
}
: {
ok: false,
text: engine.lastreadat
? `AI_engine has not read these settings since ${ago(engine.lastreadat)}, so its agents are on their environment defaults. Check it is running and can reach this API (GO_API_BASE_URL, INTERNAL_API_KEY).`
: 'AI_engine has not read these settings yet, so its agents run on their environment defaults. It needs the registry-reading build, with GO_API_BASE_URL and INTERNAL_API_KEY pointing at this API.',
},
playground.configured
? { ok: true, text: `The Test tab is live${playground.model ? ` (model ${playground.model})` : ''}: reads are redacted, writes are only proposed.` }
: { ok: false, text: 'The Test tab is off: the server has no model API key (PLAYGROUND_LLM_API_KEY).' },
{ info: true, text: 'Hub, Fleet and Route Optimizer are simulations and are not affected by these settings.' },
];
}
export default function WiringStatusBanner() {
const { data, isLoading, isError, error } = useAiStatus();
if (isLoading) {
return (
<div role="note" className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3.5 text-xs text-slate-500">
Checking what is connected…
</div>
);
}
if (isError) {
const notDeployed = error?.response?.status === 404;
return (
<div
role="note"
className="flex items-start gap-3 rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm"
>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-500" />
<p className="leading-relaxed">
<span className="font-bold text-amber-800">Connection status unavailable.</span>{' '}
{notDeployed
? 'This API server does not report agent status yet — deploy the current doormile_backend build. Changes here are still saved and audited.'
: 'The status check failed; it will retry shortly. Changes here are still saved and audited.'}
</p>
</div>
);
}
const lines = wiringLines(data);
const allOk = lines.every((l) => l.ok || l.info);
return (
<div
role="note"
className={
allOk
? 'rounded-2xl border border-emerald-200/80 bg-gradient-to-r from-emerald-50 to-teal-50 px-4 py-3.5 text-xs text-emerald-900 shadow-sm'
: 'rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm'
}
>
<p className={`font-bold mb-1.5 ${allOk ? 'text-emerald-800' : 'text-amber-800'}`}>
{allOk ? 'All connected.' : 'Partly connected.'}
</p>
<ul className="space-y-1">
{lines.map((l) => {
const Icon = l.info ? Info : l.ok ? CheckCircle2 : XCircle;
const color = l.info ? 'text-slate-400' : l.ok ? 'text-emerald-600' : 'text-amber-600';
return (
<li key={l.text} className="flex items-start gap-2 leading-relaxed">
<Icon className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${color}`} />
<span>{l.text}</span>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -25,6 +25,7 @@ jest.mock('@/api/doormile', () => ({
createAiSkill: jest.fn(), createAiSkill: jest.fn(),
updateAiAgent: jest.fn(), updateAiAgent: jest.fn(),
getAiInsights: jest.fn(), getAiInsights: jest.fn(),
getAiStatus: jest.fn(),
getAiDecisions: jest.fn(), getAiDecisions: jest.fn(),
runAiPlayground: jest.fn(), runAiPlayground: jest.fn(),
})); }));
@@ -148,6 +149,7 @@ beforeEach(() => {
api.updateAiAgent.mockResolvedValue({ success: true }); api.updateAiAgent.mockResolvedValue({ success: true });
api.getAiInsights.mockResolvedValue(INSIGHTS); api.getAiInsights.mockResolvedValue(INSIGHTS);
api.getAiDecisions.mockResolvedValue(DECISIONS); api.getAiDecisions.mockResolvedValue(DECISIONS);
api.getAiStatus.mockResolvedValue({ engine: { readingsettings: false, lastreadat: null }, playground: { configured: false } });
}); });
// ── Adapters ──────────────────────────────────────────────────────────────── // ── Adapters ────────────────────────────────────────────────────────────────
@@ -184,18 +186,36 @@ describe('registry adapters', () => {
// ── The page ──────────────────────────────────────────────────────────────── // ── The page ────────────────────────────────────────────────────────────────
describe('Agent Studio page', () => { describe('Agent Studio page', () => {
it('says on screen which agents the registry drives, and when', async () => { it('says "All connected" only when the engine reads the registry and the Test tab has a model', async () => {
api.getAiStatus.mockResolvedValue({
engine: { readingsettings: true, lastreadat: new Date().toISOString(), telemetry: true, liveagents: 4 },
playground: { configured: true, model: 'openai/gpt-oss-120b' },
});
renderStudio(); renderStudio();
const note = screen.getByRole('note'); const note = await screen.findByText('All connected.');
expect(note).toHaveTextContent(/saved to the Doormile agent registry/); const banner = note.closest('[role="note"]');
// The engine reads it only once its Phase 5 build is deployed; say so, not "live". expect(banner).toHaveTextContent(/AI_engine is following these settings/);
expect(note).toHaveTextContent(/once its registry-reading build is deployed/); expect(banner).toHaveTextContent(/The Test tab is live \(model openai\/gpt-oss-120b\)/);
expect(note).toHaveTextContent(/environment defaults/); expect(banner).not.toHaveTextContent(/Half wired/);
expect(note).toHaveTextContent(/The Test tab runs real AI model calls/); });
expect(note).not.toHaveTextContent(/The Test tab is a simulation/);
// Since Phase 3 the console skills DO run on these settings; the note must not say otherwise. it('says what is missing when the engine has not read the registry', async () => {
expect(note).toHaveTextContent(/run on these settings now/); api.getAiStatus.mockResolvedValue({
expect(note).not.toHaveTextContent(/not merged/); engine: { readingsettings: false, lastreadat: null, telemetry: false, liveagents: 0 },
playground: { configured: false },
});
renderStudio();
const banner = (await screen.findByText('Partly connected.')).closest('[role="note"]');
expect(banner).toHaveTextContent(/AI_engine has not read these settings yet/);
expect(banner).toHaveTextContent(/environment defaults/);
expect(banner).toHaveTextContent(/Test tab is off/);
expect(banner).toHaveTextContent(/saved to the Doormile agent registry/);
});
it('says the backend needs deploying when it has no status endpoint', async () => {
api.getAiStatus.mockRejectedValue({ response: { status: 404 } });
renderStudio();
expect(await screen.findByText(/does not report agent status yet/)).toBeInTheDocument();
}); });
it("shows only the selected agent's skills", async () => { it("shows only the selected agent's skills", async () => {
@@ -257,7 +277,7 @@ describe('Agent Studio page', () => {
renderStudio(); renderStudio();
await screen.findAllByText('Stalled-rider response'); await screen.findAllByText('Stalled-rider response');
await switchTo('Console Ops Agent'); await switchTo('Console Ops Agent');
fireEvent.click(await screen.findByText('New skill')); fireEvent.click(await screen.findByText(/^New skill$/i));
fireEvent.change(screen.getByLabelText('Skill Title *'), { target: { value: 'Night shift watch' } }); fireEvent.change(screen.getByLabelText('Skill Title *'), { target: { value: 'Night shift watch' } });
fireEvent.change(screen.getByLabelText('Description *'), { target: { value: 'Watch the night shift.' } }); fireEvent.change(screen.getByLabelText('Description *'), { target: { value: 'Watch the night shift.' } });
@@ -281,7 +301,7 @@ describe('Agent Studio page', () => {
it('does not offer a new skill on an AI_engine agent', async () => { it('does not offer a new skill on an AI_engine agent', async () => {
renderStudio(); renderStudio();
await screen.findAllByText('Stalled-rider response'); await screen.findAllByText('Stalled-rider response');
expect(screen.getByText('New skill').closest('button')).toBeDisabled(); expect(screen.getByText(/^New skill$/i).closest('button')).toBeDisabled();
}); });
it('needs the agent id typed before switching autonomy on', async () => { it('needs the agent id typed before switching autonomy on', async () => {
@@ -319,7 +339,7 @@ describe('Agent Studio page', () => {
renderStudio(); renderStudio();
await screen.findAllByText('Stalled-rider response'); await screen.findAllByText('Stalled-rider response');
expect(screen.getByText('You can view the registry. Only an admin can change it.')).toBeInTheDocument(); expect(screen.getByText('You can view the registry. Only an admin can change it.')).toBeInTheDocument();
expect(screen.getByText('New skill').closest('button')).toBeDisabled(); expect(screen.getByText(/^New skill$/i).closest('button')).toBeDisabled();
fireEvent.click(screen.getByText('Details')); fireEvent.click(screen.getByText('Details'));
expect(screen.getByRole('switch', { name: 'Stalled-rider response enabled' })).toBeDisabled(); expect(screen.getByRole('switch', { name: 'Stalled-rider response enabled' })).toBeDisabled();
@@ -331,12 +351,16 @@ describe('Agent Studio page', () => {
renderStudio(); renderStudio();
await screen.findAllByText('Stalled-rider response'); await screen.findAllByText('Stalled-rider response');
fireEvent.click(screen.getAllByText('Tools')[0]); fireEvent.click(screen.getAllByText('Tools')[0]);
const row = screen.getAllByText('nearby_milers').map((el) => el.closest('tr')).find(Boolean); // Tools render as cards (a header button per tool): kind + safety badges.
expect(within(row).getByText('Read-only')).toBeInTheDocument(); // The name also appears in the agent navigator; the tool card is the button carrying its kind badge.
expect(within(row).getByText('No confirmation needed')).toBeInTheDocument(); const toolCard = (name) =>
const gated = screen.getAllByText('reassign_booking').map((el) => el.closest('tr')).find(Boolean); screen.getAllByText(name).map((el) => el.closest('button')).find((btn) => btn && within(btn).queryByText(/^(Read|Write|Notify|General)$/));
expect(within(gated).getByText('Writes')).toBeInTheDocument(); const card = toolCard('nearby_milers');
expect(within(gated).getByText('Confirmation Required')).toBeInTheDocument(); expect(within(card).getByText('Read')).toBeInTheDocument();
expect(within(card).getByText('Open')).toBeInTheDocument();
const gated = toolCard('reassign_booking');
expect(within(gated).getByText('Write')).toBeInTheDocument();
expect(within(gated).getByText('Gated')).toBeInTheDocument();
expect(screen.queryByText('Autonomous')).not.toBeInTheDocument(); expect(screen.queryByText('Autonomous')).not.toBeInTheDocument();
}); });
@@ -406,7 +430,8 @@ describe('Agent Studio Test tab', () => {
const openTest = async () => { const openTest = async () => {
renderStudio(); renderStudio();
await screen.findAllByText('Stalled-rider response'); await screen.findAllByText('Stalled-rider response');
fireEvent.click(screen.getByText('Test')); // The tab row renders before the skill cards, whose buttons also say Test.
fireEvent.click(screen.getAllByText('Test')[0]);
}; };
it('runs the prompt on the backend and shows its real trace', async () => { it('runs the prompt on the backend and shows its real trace', async () => {