From 798c1b9fad4a1899f2f50fc0474ea6095a4fab66 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 09:05:39 +0000 Subject: [PATCH] feat(platform-service): add agent registry foundation --- .../platform-service/src/lib/cosmos-init.ts | 3 + .../src/modules/agents/repository.test.ts | 47 ++++++ .../src/modules/agents/repository.ts | 67 ++++++++ .../src/modules/agents/routes.test.ts | 99 ++++++++++++ .../src/modules/agents/routes.ts | 145 ++++++++++++++++++ .../src/modules/agents/types.ts | 116 ++++++++++++++ services/platform-service/src/server.ts | 2 + 7 files changed, 479 insertions(+) create mode 100644 services/platform-service/src/modules/agents/repository.test.ts create mode 100644 services/platform-service/src/modules/agents/repository.ts create mode 100644 services/platform-service/src/modules/agents/routes.test.ts create mode 100644 services/platform-service/src/modules/agents/routes.ts create mode 100644 services/platform-service/src/modules/agents/types.ts diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 2d697640..6c7214c3 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -71,6 +71,9 @@ const CONTAINER_DEFS: Record = { org_memberships: { partitionKeyPath: '/orgId' }, // Human review / approval queue review_queue: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 }, + // Agent registry and versioned prompt/config definitions + agent_registry: { partitionKeyPath: '/productId' }, + agent_versions: { partitionKeyPath: '/agentId' }, // Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md) telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 }, telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 }, diff --git a/services/platform-service/src/modules/agents/repository.test.ts b/services/platform-service/src/modules/agents/repository.test.ts new file mode 100644 index 00000000..7e0ae1b0 --- /dev/null +++ b/services/platform-service/src/modules/agents/repository.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js'; +import * as repo from './repository.js'; + +describe('agents repository', () => { + beforeEach(() => { + setProvider(new MemoryDatastoreProvider()); + }); + + afterEach(() => { + _resetDatastoreProvider(); + }); + + it('stores agents and versioned prompts', async () => { + await repo.createAgent({ + id: 'agt_1', + productId: 'lysnrai', + key: 'incident-responder', + name: 'Incident Responder', + status: 'draft', + visibility: 'internal', + currentVersion: 1, + tags: ['support'], + createdAt: '2026-03-15T00:00:00.000Z', + updatedAt: '2026-03-15T00:00:00.000Z', + }); + + await repo.createAgentVersion({ + id: 'agt_1:v1', + agentId: 'agt_1', + productId: 'lysnrai', + version: 1, + status: 'draft', + prompt: 'Investigate the incident carefully.', + toolBindings: ['support.runIncidentPipeline'], + createdBy: 'admin_1', + createdAt: '2026-03-15T00:00:00.000Z', + }); + + const agents = await repo.listAgents('lysnrai', { limit: 20 }); + const versions = await repo.listAgentVersions('agt_1'); + + expect(agents).toHaveLength(1); + expect(versions[0].prompt).toContain('Investigate'); + }); +}); diff --git a/services/platform-service/src/modules/agents/repository.ts b/services/platform-service/src/modules/agents/repository.ts new file mode 100644 index 00000000..cebb48e5 --- /dev/null +++ b/services/platform-service/src/modules/agents/repository.ts @@ -0,0 +1,67 @@ +import { NotFoundError } from '../../lib/errors.js'; +import { getCollection } from '../../lib/datastore.js'; +import type { AgentDefinitionDoc, AgentVersionDoc, ListAgentsQuery } from './types.js'; + +function agentCollection() { + return getCollection('agent_registry', '/productId'); +} + +function versionCollection() { + return getCollection('agent_versions', '/agentId'); +} + +export async function createAgent(doc: AgentDefinitionDoc): Promise { + return agentCollection().create(doc); +} + +export async function getAgent(id: string, productId: string): Promise { + const agent = await agentCollection().findById(id, productId); + if (!agent) throw new NotFoundError(`Agent '${id}' not found`); + return agent; +} + +export async function listAgents( + productId: string, + query: ListAgentsQuery +): Promise { + return agentCollection().findMany({ + filter: { + productId, + ...(query.status ? { status: query.status } : {}), + ...(query.visibility ? { visibility: query.visibility } : {}), + }, + sort: { createdAt: -1 }, + limit: query.limit, + }); +} + +export async function updateAgent( + id: string, + productId: string, + updates: Partial +): Promise { + const updated = await agentCollection().update(id, productId, { + ...updates, + updatedAt: new Date().toISOString(), + }); + if (!updated) throw new NotFoundError(`Agent '${id}' not found`); + return updated; +} + +export async function createAgentVersion(doc: AgentVersionDoc): Promise { + return versionCollection().create(doc); +} + +export async function listAgentVersions(agentId: string): Promise { + return versionCollection().findMany({ + filter: { agentId }, + sort: { version: -1 }, + limit: 100, + }); +} + +export async function getAgentVersion(id: string, agentId: string): Promise { + const version = await versionCollection().findById(id, agentId); + if (!version) throw new NotFoundError(`Agent version '${id}' not found`); + return version; +} diff --git a/services/platform-service/src/modules/agents/routes.test.ts b/services/platform-service/src/modules/agents/routes.test.ts new file mode 100644 index 00000000..3dc8dc31 --- /dev/null +++ b/services/platform-service/src/modules/agents/routes.test.ts @@ -0,0 +1,99 @@ +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + listAgents: vi.fn(), + createAgent: vi.fn(), + getAgent: vi.fn(), + updateAgent: vi.fn(), + createAgentVersion: vi.fn(), + listAgentVersions: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +async function buildApp(payload?: { sub: string; productId: string; role?: string }) { + const { agentRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + if (payload) { + app.addHook('onRequest', async req => { + req.jwtPayload = payload; + }); + } + await app.register(agentRoutes, { prefix: '/api' }); + return app; +} + +describe('agentRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POST /agents creates an agent with an initial version', async () => { + repoMock.createAgent.mockResolvedValue({ id: 'agt_1', key: 'incident-responder' }); + repoMock.createAgentVersion.mockResolvedValue({ id: 'agt_1:v1' }); + + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/agents', + payload: { + key: 'incident-responder', + name: 'Incident Responder', + initialVersion: { + prompt: 'Investigate incidents.', + toolBindings: ['support.runIncidentPipeline'], + }, + }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.createAgent).toHaveBeenCalled(); + expect(repoMock.createAgentVersion).toHaveBeenCalledWith( + expect.objectContaining({ + version: 1, + prompt: 'Investigate incidents.', + }) + ); + }); + + it('POST /agents/:id/versions creates a new version and advances currentVersion', async () => { + repoMock.getAgent.mockResolvedValue({ + id: 'agt_1', + productId: 'lysnrai', + currentVersion: 1, + status: 'draft', + }); + repoMock.createAgentVersion.mockResolvedValue({ id: 'agt_1:v2' }); + repoMock.updateAgent.mockResolvedValue({ id: 'agt_1', currentVersion: 2 }); + + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/agt_1/versions', + payload: { + prompt: 'Investigate incidents with tooling.', + status: 'published', + }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.createAgentVersion).toHaveBeenCalledWith( + expect.objectContaining({ + version: 2, + status: 'published', + }) + ); + expect(repoMock.updateAgent).toHaveBeenCalledWith( + 'agt_1', + 'lysnrai', + expect.objectContaining({ + currentVersion: 2, + }) + ); + }); +}); diff --git a/services/platform-service/src/modules/agents/routes.ts b/services/platform-service/src/modules/agents/routes.ts new file mode 100644 index 00000000..12100cfa --- /dev/null +++ b/services/platform-service/src/modules/agents/routes.ts @@ -0,0 +1,145 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, ForbiddenError } from '../../lib/errors.js'; +import { + AgentDefinitionDoc, + AgentVersionDoc, + CreateAgentSchema, + CreateAgentVersionSchema, + ListAgentsQuerySchema, + UpdateAgentSchema, +} from './types.js'; +import * as repo from './repository.js'; + +function requireAdmin(req: { jwtPayload?: { sub?: string; role?: string; productId?: string } }): { + userId: string; + productId: string; +} { + const payload = req.jwtPayload; + if (!payload?.sub) throw new ForbiddenError('Authentication required'); + if (!payload.role || !['super_admin', 'admin'].includes(payload.role)) { + throw new ForbiddenError('Admin access required'); + } + return { + userId: payload.sub, + productId: payload.productId ?? process.env.DEFAULT_PRODUCT_ID ?? 'lysnrai', + }; +} + +export async function agentRoutes(app: FastifyInstance) { + app.get('/agents', async req => { + const access = requireAdmin(req); + const parsed = ListAgentsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.listAgents(access.productId, parsed.data); + }); + + app.post('/agents', async req => { + const access = requireAdmin(req); + const parsed = CreateAgentSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + + const now = new Date().toISOString(); + const agentId = `agt_${randomUUID()}`; + const versionNumber = 1; + + const agent: AgentDefinitionDoc = { + id: agentId, + productId: access.productId, + key: parsed.data.key, + name: parsed.data.name, + description: parsed.data.description, + ownerTeam: parsed.data.ownerTeam, + status: 'draft', + visibility: parsed.data.visibility, + currentVersion: versionNumber, + tags: parsed.data.tags, + metadata: parsed.data.metadata, + createdAt: now, + updatedAt: now, + }; + + const created = await repo.createAgent(agent); + + if (parsed.data.initialVersion) { + const version: AgentVersionDoc = { + id: `${agentId}:v${versionNumber}`, + agentId, + productId: access.productId, + version: versionNumber, + status: 'draft', + prompt: parsed.data.initialVersion.prompt, + systemInstructions: parsed.data.initialVersion.systemInstructions, + modelPolicy: parsed.data.initialVersion.modelPolicy, + toolBindings: parsed.data.initialVersion.toolBindings, + config: parsed.data.initialVersion.config, + changeSummary: parsed.data.initialVersion.changeSummary, + createdBy: access.userId, + createdAt: now, + }; + await repo.createAgentVersion(version); + } + + return created; + }); + + app.get('/agents/:id', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + return repo.getAgent(id, access.productId); + }); + + app.patch('/agents/:id', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const parsed = UpdateAgentSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.updateAgent(id, access.productId, parsed.data); + }); + + app.get('/agents/:id/versions', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + return repo.listAgentVersions(id); + }); + + app.post('/agents/:id/versions', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const agent = await repo.getAgent(id, access.productId); + const parsed = CreateAgentVersionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + + const nextVersion = agent.currentVersion + 1; + const version: AgentVersionDoc = { + id: `${id}:v${nextVersion}`, + agentId: id, + productId: access.productId, + version: nextVersion, + status: parsed.data.status, + prompt: parsed.data.prompt, + systemInstructions: parsed.data.systemInstructions, + modelPolicy: parsed.data.modelPolicy, + toolBindings: parsed.data.toolBindings, + config: parsed.data.config, + changeSummary: parsed.data.changeSummary, + createdBy: access.userId, + createdAt: new Date().toISOString(), + }; + + const createdVersion = await repo.createAgentVersion(version); + await repo.updateAgent(id, access.productId, { + currentVersion: nextVersion, + status: parsed.data.status === 'published' ? 'active' : agent.status, + }); + return createdVersion; + }); +} diff --git a/services/platform-service/src/modules/agents/types.ts b/services/platform-service/src/modules/agents/types.ts new file mode 100644 index 00000000..6585e563 --- /dev/null +++ b/services/platform-service/src/modules/agents/types.ts @@ -0,0 +1,116 @@ +import { z } from 'zod'; + +export const AgentStatusSchema = z.enum(['draft', 'active', 'deprecated', 'archived']); +export const AgentVisibilitySchema = z.enum(['private', 'internal', 'public']); + +export const AgentDefinitionSchema = z.object({ + id: z.string().min(1), + productId: z.string().min(1), + key: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + ownerTeam: z.string().optional(), + status: AgentStatusSchema, + visibility: AgentVisibilitySchema, + currentVersion: z.number().int().positive().default(1), + tags: z.array(z.string()).default([]), + metadata: z.record(z.unknown()).optional(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type AgentDefinitionDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const AgentVersionSchema = z.object({ + id: z.string().min(1), + agentId: z.string().min(1), + productId: z.string().min(1), + version: z.number().int().positive(), + status: z.enum(['draft', 'published', 'deprecated']).default('draft'), + prompt: z.string().min(1), + systemInstructions: z.string().optional(), + modelPolicy: z + .object({ + provider: z.string().optional(), + model: z.string().optional(), + temperature: z.number().optional(), + maxTokens: z.number().optional(), + }) + .optional(), + toolBindings: z.array(z.string()).default([]), + config: z.record(z.unknown()).optional(), + changeSummary: z.string().optional(), + createdBy: z.string().min(1), + createdAt: z.string(), +}); + +export type AgentVersionDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const CreateAgentSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + ownerTeam: z.string().optional(), + visibility: AgentVisibilitySchema.default('internal'), + tags: z.array(z.string()).default([]), + metadata: z.record(z.unknown()).optional(), + initialVersion: z + .object({ + prompt: z.string().min(1), + systemInstructions: z.string().optional(), + modelPolicy: z + .object({ + provider: z.string().optional(), + model: z.string().optional(), + temperature: z.number().optional(), + maxTokens: z.number().optional(), + }) + .optional(), + toolBindings: z.array(z.string()).default([]), + config: z.record(z.unknown()).optional(), + changeSummary: z.string().optional(), + }) + .optional(), +}); + +export const UpdateAgentSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional(), + ownerTeam: z.string().optional(), + status: AgentStatusSchema.optional(), + visibility: AgentVisibilitySchema.optional(), + tags: z.array(z.string()).optional(), + metadata: z.record(z.unknown()).optional(), + currentVersion: z.number().int().positive().optional(), +}); + +export const CreateAgentVersionSchema = z.object({ + prompt: z.string().min(1), + systemInstructions: z.string().optional(), + modelPolicy: z + .object({ + provider: z.string().optional(), + model: z.string().optional(), + temperature: z.number().optional(), + maxTokens: z.number().optional(), + }) + .optional(), + toolBindings: z.array(z.string()).default([]), + config: z.record(z.unknown()).optional(), + changeSummary: z.string().optional(), + status: z.enum(['draft', 'published', 'deprecated']).default('draft'), +}); + +export const ListAgentsQuerySchema = z.object({ + status: AgentStatusSchema.optional(), + visibility: AgentVisibilitySchema.optional(), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +export type ListAgentsQuery = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 8965a762..e7696a9d 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -35,6 +35,7 @@ import { qrAuthRoutes } from './modules/auth/qr-auth/routes.js'; import { enterpriseRoutes } from './modules/auth/enterprise/routes.js'; import { magicLinkRoutes } from './modules/auth/magic-link/routes.js'; import { auditRoutes } from './modules/audit/routes.js'; +import { agentRoutes } from './modules/agents/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; import { flagRoutes } from './modules/flags/routes.js'; import { rateLimitRoutes } from './modules/ratelimit/routes.js'; @@ -139,6 +140,7 @@ await app.register(qrAuthRoutes, { prefix: '/api' }); await app.register(enterpriseRoutes, { prefix: '/api' }); await app.register(magicLinkRoutes, { prefix: '/api' }); await app.register(auditRoutes, { prefix: '/api' }); +await app.register(agentRoutes, { prefix: '/api' }); await app.register(notificationRoutes, { prefix: '/api' }); await app.register(flagRoutes, { prefix: '/api' }); await app.register(rateLimitRoutes, { prefix: '/api' });