feat(platform-service): add agent registry foundation
This commit is contained in:
parent
473b7310d5
commit
798c1b9fad
@ -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 },
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
67
services/platform-service/src/modules/agents/repository.ts
Normal file
67
services/platform-service/src/modules/agents/repository.ts
Normal 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;
|
||||
}
|
||||
99
services/platform-service/src/modules/agents/routes.test.ts
Normal file
99
services/platform-service/src/modules/agents/routes.test.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
145
services/platform-service/src/modules/agents/routes.ts
Normal file
145
services/platform-service/src/modules/agents/routes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
116
services/platform-service/src/modules/agents/types.ts
Normal file
116
services/platform-service/src/modules/agents/types.ts
Normal 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>;
|
||||
@ -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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user