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

@@ -2,13 +2,37 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
MANIFEST = AgentManifest(
id="focus-area",
version="1.0.0",
description="Identifies the most congested project/area in the user's task list.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"preferred_areas": {
"type": "array",
"items": {"type": "string"},
"default": [],
"description": "Project / label names to prioritise when multiple areas tie.",
},
},
},
context_schema=["todoist.tasks"],
required_consents=["data:core", "data:todoist", "agent:focus-area"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=43_200,
)
class FocusAreaAgent(BaseAgent): class FocusAreaAgent(BaseAgent):
"""Identifies the most congested project/area in the user's task list.""" """Identifies the most congested project/area in the user's task list."""
agent_id: ClassVar[str] = "focus-area" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 43_200 # 12h ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
by_project: dict[str, list[dict]] = defaultdict(list) by_project: dict[str, list[dict]] = defaultdict(list)

70
ml/agents/manifest.py Normal file
View File

@@ -0,0 +1,70 @@
"""Agent manifest dataclass (ADR-0014).
A manifest is the single point of registration for an agent. The orchestrator,
admin UI, registry endpoint, and inference framework all read from it. Adding
an agent is adding a manifest + agent class — never editing a list elsewhere.
The manifest lives next to the agent code (each agent module in ml/agents/
exposes a module-level `MANIFEST` constant). The registry surfaces both the
agent instance and its manifest.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
@dataclass(frozen=True)
class InferredParam:
"""One auto-inferred preference key (#111-#116).
The inference framework owns scheduling, history reads, persistence, and
cold-start. Each agent's `inferred_params` list declares what to infer and
how, leaving each agent to implement just `infer()`.
"""
key: str # e.g. 'quietStart'
ttl_sec: int # how often to recompute
cold_start_default: Any # value used until min_history is met
min_history: int # event count threshold
# Pure function: given a UserHistory snapshot, return the inferred value.
# Typed as a generic callable here; concrete signature lives in the framework.
infer: Callable[[Any], Any] | None = None
@dataclass(frozen=True)
class AgentManifest:
"""Declarative description of an agent — see ADR-0014 §1."""
id: str # 'time-of-day'
version: str # bump invalidates cached outputs + inferences
description: str # one-line human summary for admin UI
pref_schema: dict # JSON Schema for user-tunable knobs
context_schema: list[str] # signals it reads, e.g. ['todoist.tasks']
required_consents: list[str] # ['data:todoist', 'agent:time-of-day']
output_contract: dict # snippet shape (free text + optional tags)
ttl_sec: int # snippet freshness for agent_outputs
silenced_in_contexts: list[str] = field(default_factory=list) # active context names that suppress this agent
inferred_params: list[InferredParam] = field(default_factory=list)
def to_dict(self) -> dict:
"""Serialise for the registry endpoint. `inferred_params` drops `infer`
(callable) since the wire format only carries metadata."""
return {
"id": self.id,
"version": self.version,
"description": self.description,
"pref_schema": self.pref_schema,
"context_schema": self.context_schema,
"required_consents": self.required_consents,
"output_contract": self.output_contract,
"ttl_sec": self.ttl_sec,
"silenced_in_contexts": list(self.silenced_in_contexts),
"inferred_params": [
{
"key": p.key,
"ttl_sec": p.ttl_sec,
"cold_start_default": p.cold_start_default,
"min_history": p.min_history,
}
for p in self.inferred_params
],
}

View File

@@ -1,13 +1,38 @@
from __future__ import annotations from __future__ import annotations
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
MANIFEST = AgentManifest(
id="momentum",
version="1.0.0",
description="Characterises the user's recent engagement trend from profile features.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"low_engagement_threshold_pct": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"default": 25,
"description": "Completion rate below which momentum hints at low engagement.",
},
},
},
context_schema=["profile.features"],
required_consents=["data:core", "agent:momentum"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=21_600,
)
class MomentumAgent(BaseAgent): class MomentumAgent(BaseAgent):
"""Characterises the user's recent engagement trend from profile features.""" """Characterises the user's recent engagement trend from profile features."""
agent_id: ClassVar[str] = "momentum" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 21600 # 6h ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
completion = inp.profile.get("completion_rate_30d") completion = inp.profile.get("completion_rate_30d")

View File

@@ -1,13 +1,38 @@
from __future__ import annotations from __future__ import annotations
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
MANIFEST = AgentManifest(
id="overdue-task",
version="1.0.0",
description="Reports the user's overdue tasks by count and age.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"lateness_tolerance_days": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Days past due before a task is considered overdue. 0 = the moment it's late.",
},
},
},
context_schema=["todoist.tasks"],
required_consents=["data:core", "data:todoist", "agent:overdue-task"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=3600,
silenced_in_contexts=["vacation"],
)
class OverdueTaskAgent(BaseAgent): class OverdueTaskAgent(BaseAgent):
"""Reports the user's overdue tasks by count and age.""" """Reports the user's overdue tasks by count and age."""
agent_id: ClassVar[str] = "overdue-task" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 3600 # 1h — overdue status changes infrequently ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
overdue = [t for t in inp.tasks if t.get("is_overdue")] overdue = [t for t in inp.tasks if t.get("is_overdue")]

View File

@@ -3,15 +3,40 @@ from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
_SEVEN_DAYS_S = 7 * 86_400 _SEVEN_DAYS_S = 7 * 86_400
MANIFEST = AgentManifest(
id="recent-patterns",
version="1.0.0",
description="Surfaces the user's reaction pattern from the last 7 days of feedback.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"window_days": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"default": 7,
"description": "Lookback window for pattern analysis.",
},
},
},
context_schema=["tip_feedback", "profile.features"],
required_consents=["data:core", "agent:recent-patterns"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=86_400,
)
class RecentPatternsAgent(BaseAgent): class RecentPatternsAgent(BaseAgent):
"""Surfaces the user's reaction pattern from the last 7 days of feedback.""" """Surfaces the user's reaction pattern from the last 7 days of feedback."""
agent_id: ClassVar[str] = "recent-patterns" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 86_400 # 24h ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
now_ts = inp.now.timestamp() now_ts = inp.now.timestamp()

View File

@@ -1,21 +1,41 @@
from __future__ import annotations """Agent registry — single point of registration for sub-agents (ADR-0014).
from .base import BaseAgent
from .overdue_task import OverdueTaskAgent
from .momentum import MomentumAgent
from .time_of_day import TimeOfDayAgent
from .recent_patterns import RecentPatternsAgent
from .focus_area import FocusAreaAgent
_AGENTS: dict[str, BaseAgent] = { Each agent module contributes:
a.agent_id: a - a `BaseAgent` subclass instance
for a in [ - a module-level `MANIFEST: AgentManifest`
OverdueTaskAgent(),
MomentumAgent(), The orchestrator, registry endpoint, and inference framework all read from
TimeOfDayAgent(), here. Adding an agent is: add a module, register it once below.
RecentPatternsAgent(), """
FocusAreaAgent(), from __future__ import annotations
from .base import BaseAgent
from .manifest import AgentManifest
from .overdue_task import OverdueTaskAgent, MANIFEST as OVERDUE_TASK_MANIFEST
from .momentum import MomentumAgent, MANIFEST as MOMENTUM_MANIFEST
from .time_of_day import TimeOfDayAgent, MANIFEST as TIME_OF_DAY_MANIFEST
from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST
from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
(MomentumAgent(), MOMENTUM_MANIFEST),
(TimeOfDayAgent(), TIME_OF_DAY_MANIFEST),
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
] ]
}
# Sanity check — agent_id and manifest.id must agree, otherwise the registry
# becomes inconsistent across endpoints.
for _agent, _manifest in _REGISTERED:
if _agent.agent_id != _manifest.id:
raise RuntimeError(
f"Manifest mismatch: {_agent.__class__.__name__}.agent_id={_agent.agent_id!r} "
f"≠ MANIFEST.id={_manifest.id!r}"
)
_AGENTS: dict[str, BaseAgent] = {a.agent_id: a for a, _ in _REGISTERED}
_MANIFESTS: dict[str, AgentManifest] = {m.id: m for _, m in _REGISTERED}
def get_agent(agent_id: str) -> BaseAgent: def get_agent(agent_id: str) -> BaseAgent:
@@ -26,3 +46,13 @@ def get_agent(agent_id: str) -> BaseAgent:
def all_agents() -> list[BaseAgent]: def all_agents() -> list[BaseAgent]:
return list(_AGENTS.values()) return list(_AGENTS.values())
def get_manifest(agent_id: str) -> AgentManifest:
if agent_id not in _MANIFESTS:
raise KeyError(f"Unknown agent: {agent_id!r}. Known: {sorted(_MANIFESTS)}")
return _MANIFESTS[agent_id]
def all_manifests() -> list[AgentManifest]:
return list(_MANIFESTS.values())

View File

@@ -0,0 +1,67 @@
"""Manifest registry tests (ADR-0014).
Each agent module exports a `MANIFEST: AgentManifest` whose id and version
must agree with the agent class. The registry exposes both, and `to_dict()`
must drop the `infer` callable so the wire payload is JSON-serialisable.
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
import pytest # noqa: E402
from ml.agents.manifest import AgentManifest, InferredParam # noqa: E402
from ml.agents.registry import ( # noqa: E402
all_agents,
all_manifests,
get_agent,
get_manifest,
)
def test_every_agent_has_a_matching_manifest():
agents = {a.agent_id: a for a in all_agents()}
manifests = {m.id: m for m in all_manifests()}
assert agents.keys() == manifests.keys(), "agent / manifest registries diverged"
for aid in agents:
assert agents[aid].version == manifests[aid].version, (
f"version mismatch for {aid}: agent={agents[aid].version!r} "
f"manifest={manifests[aid].version!r}"
)
@pytest.mark.parametrize("agent_id", [
"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area",
])
def test_manifest_required_fields(agent_id: str):
m = get_manifest(agent_id)
assert m.id == agent_id
assert m.version
assert m.description
assert isinstance(m.pref_schema, dict) and m.pref_schema.get("type") == "object"
assert isinstance(m.required_consents, list) and m.required_consents
assert "data:core" in m.required_consents, "every agent should require data:core"
assert m.ttl_sec == get_agent(agent_id).ttl_seconds, "ttl divergence"
def test_to_dict_is_json_serialisable_and_drops_infer_callable():
m = AgentManifest(
id="x", version="1.0.0", description="d",
pref_schema={"type": "object"}, context_schema=[], required_consents=["data:core"],
output_contract={"type": "snippet"}, ttl_sec=60,
inferred_params=[InferredParam(key="k", ttl_sec=60, cold_start_default=0, min_history=10, infer=lambda h: 0)],
)
payload = m.to_dict()
# Round-trip through json to confirm no callables / non-JSON types leaked.
data = json.loads(json.dumps(payload))
assert data["inferred_params"][0]["key"] == "k"
assert "infer" not in data["inferred_params"][0]
def test_get_manifest_unknown_raises():
with pytest.raises(KeyError):
get_manifest("not-an-agent")

View File

@@ -1,15 +1,43 @@
from __future__ import annotations from __future__ import annotations
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest
_DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] _DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
MANIFEST = AgentManifest(
id="time-of-day",
version="1.0.0",
description="Frames the current moment relative to the user's productive peak and quiet hours.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"quiet_start": {
"type": "string",
"pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
"description": "HH:MM start of quiet hours (24h, user's local TZ).",
},
"quiet_end": {
"type": "string",
"pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
"description": "HH:MM end of quiet hours.",
},
},
},
context_schema=["profile.features"],
required_consents=["data:core", "agent:time-of-day"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=900,
)
class TimeOfDayAgent(BaseAgent): class TimeOfDayAgent(BaseAgent):
"""Frames the current moment relative to the user's productive peak.""" """Frames the current moment relative to the user's productive peak."""
agent_id: ClassVar[str] = "time-of-day" agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = 900 # 15m — must stay current-hour accurate ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = "1.0.0" version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
hour = inp.now.hour hour = inp.now.hour

View File

@@ -38,7 +38,7 @@ if _repo_root not in sys.path:
sys.path.insert(0, _repo_root) sys.path.insert(0, _repo_root)
from ml.agents.base import AgentInput # noqa: E402 from ml.agents.base import AgentInput # noqa: E402
from ml.agents.registry import get_agent, all_agents # noqa: E402 from ml.agents.registry import get_agent, all_agents, all_manifests # noqa: E402
logging_config.configure() logging_config.configure()
@@ -177,6 +177,16 @@ def health():
} }
@app.get("/agents/registry")
def agents_registry():
"""Manifest list for every registered agent (ADR-0014).
Consumers: TS recommender (eligibility filter), admin UI (auto-rendered
pref forms), inference framework (#111). Static at process boot.
"""
return {"agents": [m.to_dict() for m in all_manifests()]}
_RETRY_SUFFIX = ( _RETRY_SUFFIX = (
"\n\nYour previous response was not valid JSON. " "\n\nYour previous response was not valid JSON. "
"Reply ONLY with the JSON array — no prose, no markdown fences." "Reply ONLY with the JSON array — no prose, no markdown fences."

View File

@@ -0,0 +1,21 @@
"""GET /agents/registry — manifests are exposed in JSON-serialisable form."""
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
@pytest.mark.anyio
async def test_registry_returns_all_agents():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/agents/registry")
assert resp.status_code == 200
payload = resp.json()
ids = {a["id"] for a in payload["agents"]}
assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area"}
sample = payload["agents"][0]
for key in ("id", "version", "description", "pref_schema", "required_consents", "ttl_sec"):
assert key in sample

View File

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