import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, NotFoundException, Param, Patch, Post, UseGuards, } from '@nestjs/common'; import type { Brain } from '@mosaicstack/brain'; import { BRAIN } from '../brain/brain.tokens.js'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; import { CreateAgentConfigDto, UpdateAgentConfigDto } from './agent-config.dto.js'; // ─── M4-011 helpers ────────────────────────────────────────────────────────── type CapabilityFields = { domains?: string[] | null; preferredModel?: string | null; preferredProvider?: string | null; toolSets?: string[] | null; }; /** Extract capability shorthand fields from the DTO (undefined if none provided). */ function buildCapabilities(dto: CapabilityFields): Record | undefined { const hasAny = dto.domains !== undefined || dto.preferredModel !== undefined || dto.preferredProvider !== undefined || dto.toolSets !== undefined; if (!hasAny) return undefined; const cap: Record = {}; if (dto.domains !== undefined) cap['domains'] = dto.domains; if (dto.preferredModel !== undefined) cap['preferredModel'] = dto.preferredModel; if (dto.preferredProvider !== undefined) cap['preferredProvider'] = dto.preferredProvider; if (dto.toolSets !== undefined) cap['toolSets'] = dto.toolSets; return cap; } /** Merge capabilities into the config object, preserving other config keys. */ function mergeCapabilities( existing: Record | null | undefined, capabilities: Record | undefined, ): Record | undefined { if (capabilities === undefined && existing === undefined) return undefined; if (capabilities === undefined) return existing ?? undefined; const base = existing ?? {}; const existingCap = typeof base['capabilities'] === 'object' && base['capabilities'] !== null ? (base['capabilities'] as Record) : {}; return { ...base, capabilities: { ...existingCap, ...capabilities }, }; } @Controller('api/agents') @UseGuards(AuthGuard) export class AgentConfigsController { constructor(@Inject(BRAIN) private readonly brain: Brain) {} @Get() async list(@CurrentUser() user: { id: string; role?: string }) { return this.brain.agents.findAccessible(user.id); } @Get(':id') async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) { const agent = await this.brain.agents.findById(id); if (!agent) throw new NotFoundException('Agent not found'); if (!agent.isSystem && agent.ownerId !== user.id) { throw new ForbiddenException('Agent does not belong to the current user'); } return agent; } @Post() async create(@Body() dto: CreateAgentConfigDto, @CurrentUser() user: { id: string }) { // Merge capability shorthand fields into config.capabilities (M4-011) const capabilities = buildCapabilities(dto); const config = mergeCapabilities(dto.config, capabilities); return this.brain.agents.create({ name: dto.name, provider: dto.provider, model: dto.model, status: dto.status, projectId: dto.projectId, systemPrompt: dto.systemPrompt, allowedTools: dto.allowedTools, skills: dto.skills, isSystem: false, config, ownerId: user.id, }); } @Patch(':id') async update( @Param('id') id: string, @Body() dto: UpdateAgentConfigDto, @CurrentUser() user: { id: string; role?: string }, ) { const agent = await this.brain.agents.findById(id); if (!agent) throw new NotFoundException('Agent not found'); if (agent.isSystem && user.role !== 'admin') { throw new ForbiddenException('Only admins can update system agents'); } if (!agent.isSystem && agent.ownerId !== user.id) { throw new ForbiddenException('Agent does not belong to the current user'); } // Merge capability shorthand fields into config.capabilities (M4-011) const capabilities = buildCapabilities(dto); const baseConfig = dto.config !== undefined ? dto.config : (agent.config as Record | null | undefined); const config = mergeCapabilities(baseConfig ?? undefined, capabilities); // Pass ownerId for user agents so the repo WHERE clause enforces ownership. // For system agents (admin path) pass undefined so the WHERE matches only on id. const ownerId = agent.isSystem ? undefined : user.id; const updated = await this.brain.agents.update( id, { name: dto.name, provider: dto.provider, model: dto.model, status: dto.status, projectId: dto.projectId, systemPrompt: dto.systemPrompt, allowedTools: dto.allowedTools, skills: dto.skills, config: capabilities !== undefined || dto.config !== undefined ? config : undefined, }, ownerId, ); if (!updated) throw new NotFoundException('Agent not found'); return updated; } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param('id') id: string, @CurrentUser() user: { id: string; role?: string }) { const agent = await this.brain.agents.findById(id); if (!agent) throw new NotFoundException('Agent not found'); if (agent.isSystem) { throw new ForbiddenException('Cannot delete system agents'); } if (agent.ownerId !== user.id) { throw new ForbiddenException('Agent does not belong to the current user'); } // Pass ownerId so the repo WHERE clause enforces ownership at the DB level. const deleted = await this.brain.agents.remove(id, user.id); if (!deleted) throw new NotFoundException('Agent not found'); } }