feat(agents): manifest plumbing + GET /agents/registry (ADR-0014 step 3)

Each agent now exports a module-level MANIFEST declaring id, version,
pref_schema, required_consents, ttl_sec, and silenced_in_contexts. The
registry surfaces both the agent and its manifest, and rejects on
mismatch so the two cannot drift.

ml/serving exposes GET /agents/registry; services/api proxies it as
GET /api/agents/registry with a 60s in-process cache so admin pageviews
don't hammer upstream. Failures aren't cached.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 10:55:54 +00:00
parent 5d43339616
commit 305eeae38b
13 changed files with 511 additions and 33 deletions

View File

@@ -18,6 +18,7 @@ import { pushRouter } from './routes/push.js';
import { adminRouter, adminInternalRouter } from './routes/admin.js';
import benchRouter from './routes/bench.js';
import agentOutputsRouter from './routes/agent-outputs.js';
import agentRegistryRouter from './routes/agent-registry.js';
import { mkdir } from 'fs/promises';
import { dirname } from 'path';
import { requireAuth } from './middleware/session.js';
@@ -70,6 +71,8 @@ app.use('/api/push', pushRouter);
app.use('/api/admin', adminRouter);
app.use('/api/admin', adminInternalRouter);
app.use('/api/bench', requireAuth as any, requireAdmin as any, benchRouter);
// agent-registry mounts first so /registry beats agent-outputs' /:userId pattern.
app.use('/api/agents', agentRegistryRouter);
app.use('/api/agents', agentOutputsRouter);
app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {

View File

@@ -0,0 +1,108 @@
/**
* GET /api/agents/registry — proxies ml/serving's manifest list with a short
* in-process cache. Tests stub global fetch and verify caching + 502 fallback.
*/
import { describe, it, expect, vi, beforeAll, afterEach, beforeEach } from 'vitest';
import express from 'express';
import * as http from 'http';
vi.mock('../../middleware/session.js', () => ({
sessionMiddleware: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as any).userId = 'user-1';
next();
},
}));
const REGISTRY_PAYLOAD = {
agents: [
{ id: 'overdue-task', version: '1.0.0', pref_schema: { type: 'object' } },
{ id: 'momentum', version: '1.0.0', pref_schema: { type: 'object' } },
],
};
function get(url: string): Promise<{ status: number; body: any }> {
return new Promise((resolve, reject) => {
const u = new URL(url);
http.get({ hostname: u.hostname, port: Number(u.port), path: u.pathname }, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => {
try { resolve({ status: res.statusCode ?? 0, body: data ? JSON.parse(data) : null }); }
catch { resolve({ status: res.statusCode ?? 0, body: data }); }
});
}).on('error', reject);
});
}
describe('GET /api/agents/registry', () => {
let server: http.Server;
let baseUrl: string;
let savedFetch: typeof globalThis.fetch;
let resetCache: () => void;
beforeAll(async () => {
const mod = await import('../agent-registry.js');
const router = mod.default;
resetCache = mod._resetRegistryCache;
const app = express();
app.use('/api/agents', router);
server = await new Promise<http.Server>((resolve) => {
const s = app.listen(0, () => resolve(s));
});
const addr = server.address() as { port: number };
baseUrl = `http://localhost:${addr.port}`;
savedFetch = globalThis.fetch;
});
beforeEach(() => {
resetCache();
});
afterEach(() => {
globalThis.fetch = savedFetch;
});
it('proxies ml/serving manifests', async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const r = await get(`${baseUrl}/api/agents/registry`);
expect(r.status).toBe(200);
expect(r.body).toEqual(REGISTRY_PAYLOAD);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('caches across calls within the TTL', async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await get(`${baseUrl}/api/agents/registry`);
await get(`${baseUrl}/api/agents/registry`);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('returns 502 when ml/serving fails', async () => {
globalThis.fetch = vi.fn(async () => new Response('boom', { status: 500 })) as unknown as typeof fetch;
const r = await get(`${baseUrl}/api/agents/registry`);
expect(r.status).toBe(502);
expect(r.body.error).toBe('ml/serving unavailable');
});
it('does not cache failures', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
.mockResolvedValueOnce(new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const first = await get(`${baseUrl}/api/agents/registry`);
expect(first.status).toBe(502);
const second = await get(`${baseUrl}/api/agents/registry`);
expect(second.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,42 @@
import { Router, type Request, type Response, type IRouter } from 'express';
import { config } from '../config.js';
import { requireAuth } from '../middleware/session.js';
const router: IRouter = Router();
// Manifests change only on ml/serving restart, so a small in-process cache
// avoids hammering the upstream on every admin pageview / profile fetch.
const CACHE_TTL_MS = 60_000;
let _cache: { fetchedAt: number; payload: unknown } | null = null;
export function _resetRegistryCache() {
_cache = null;
}
async function fetchRegistry(): Promise<unknown> {
if (_cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) return _cache.payload;
const upstream = await fetch(`${config.ML_SERVING_URL}/agents/registry`, {
signal: AbortSignal.timeout(5000),
});
if (!upstream.ok) {
throw new Error(`ml/serving /agents/registry returned ${upstream.status}`);
}
const payload = await upstream.json();
_cache = { fetchedAt: Date.now(), payload };
return payload;
}
// ── GET /api/agents/registry ─────────────────────────────────────────────────
// Manifest list for every registered agent (ADR-0014). Auth-gated: manifests
// drive admin UI form rendering and feed the orchestrator eligibility filter.
router.get('/registry', requireAuth as any, async (_req: Request, res: Response) => {
try {
const payload = await fetchRegistry();
res.json(payload);
} catch (err: any) {
res.status(502).json({ error: 'ml/serving unavailable', detail: err.message });
}
});
export default router;