feat(platform-service): add agent registry foundation

This commit is contained in:
root 2026-03-15 09:05:39 +00:00
parent 473b7310d5
commit 798c1b9fad
7 changed files with 479 additions and 0 deletions

View File

@ -71,6 +71,9 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
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 },

View File

@ -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');
});
});

View File

@ -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<AgentDefinitionDoc>('agent_registry', '/productId');
}
function versionCollection() {
return getCollection<AgentVersionDoc>('agent_versions', '/agentId');
}
export async function createAgent(doc: AgentDefinitionDoc): Promise<AgentDefinitionDoc> {
return agentCollection().create(doc);
}
export async function getAgent(id: string, productId: string): Promise<AgentDefinitionDoc> {
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<AgentDefinitionDoc[]> {
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<AgentDefinitionDoc>
): Promise<AgentDefinitionDoc> {
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<AgentVersionDoc> {
return versionCollection().create(doc);
}
export async function listAgentVersions(agentId: string): Promise<AgentVersionDoc[]> {
return versionCollection().findMany({
filter: { agentId },
sort: { version: -1 },
limit: 100,
});
}
export async function getAgentVersion(id: string, agentId: string): Promise<AgentVersionDoc> {
const version = await versionCollection().findById(id, agentId);
if (!version) throw new NotFoundError(`Agent version '${id}' not found`);
return version;
}

View File

@ -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,
})
);
});
});

View File

@ -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;
});
}

View File

@ -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<typeof AgentDefinitionSchema> & {
_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<typeof AgentVersionSchema> & {
_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<typeof ListAgentsQuerySchema>;

View File

@ -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' });