feat(M4-009,M4-010,M4-011): routing rules CRUD API, per-user overrides, agent capabilities
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed

- Add routing.dto.ts with validation DTOs for create, update, and reorder operations
- Add routing.controller.ts with full CRUD: GET list, POST create, PATCH update,
  DELETE remove, PATCH reorder, GET effective (merged priority view)
- Users can only create/modify/delete their own user-scoped rules; system rules are
  protected with ForbiddenException
- GET /api/routing/rules/effective returns merged rule set with user rules taking
  precedence over system rules at the same priority level (M4-010)
- Extend agent-config.dto.ts with capability shorthand fields: domains, preferredModel,
  preferredProvider, toolSets (M4-011)
- Update agent-configs.controller.ts to merge capability fields into config.capabilities
  so agent's preferred model/provider can influence routing decisions
- Register RoutingController in agent.module.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 19:47:23 -05:00
parent 059962fe33
commit f22ce0096b
6 changed files with 590 additions and 10 deletions

View File

@@ -11,6 +11,51 @@ import {
const agentStatuses = ['idle', 'active', 'error', 'offline'] as const; const agentStatuses = ['idle', 'active', 'error', 'offline'] as const;
// ─── Agent Capability Declarations (M4-011) ───────────────────────────────────
/**
* Agent specialization capability fields.
* Stored inside the agent's `config` JSON as `capabilities`.
*/
export class AgentCapabilitiesDto {
/**
* Domains this agent specializes in, e.g. ['frontend', 'backend', 'devops'].
* Used by the routing engine to bias toward this agent for matching domains.
*/
@IsOptional()
@IsArray()
@IsString({ each: true })
domains?: string[];
/**
* Default model identifier for this agent.
* Influences routing when no explicit rule overrides the choice.
*/
@IsOptional()
@IsString()
@MaxLength(255)
preferredModel?: string;
/**
* Default provider for this agent.
* Influences routing when no explicit rule overrides the choice.
*/
@IsOptional()
@IsString()
@MaxLength(255)
preferredProvider?: string;
/**
* Tool categories this agent has access to, e.g. ['web-search', 'code-exec'].
*/
@IsOptional()
@IsArray()
@IsString({ each: true })
toolSets?: string[];
}
// ─── Create DTO ───────────────────────────────────────────────────────────────
export class CreateAgentConfigDto { export class CreateAgentConfigDto {
@IsString() @IsString()
@MaxLength(255) @MaxLength(255)
@@ -49,11 +94,40 @@ export class CreateAgentConfigDto {
@IsBoolean() @IsBoolean()
isSystem?: boolean; isSystem?: boolean;
/**
* General config blob. May include `capabilities` (AgentCapabilitiesDto)
* for agent specialization declarations (M4-011).
*/
@IsOptional() @IsOptional()
@IsObject() @IsObject()
config?: Record<string, unknown>; config?: Record<string, unknown>;
// ─── Capability shorthand fields (M4-011) ──────────────────────────────────
// These are convenience top-level fields that get merged into config.capabilities.
@IsOptional()
@IsArray()
@IsString({ each: true })
domains?: string[];
@IsOptional()
@IsString()
@MaxLength(255)
preferredModel?: string;
@IsOptional()
@IsString()
@MaxLength(255)
preferredProvider?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
toolSets?: string[];
} }
// ─── Update DTO ───────────────────────────────────────────────────────────────
export class UpdateAgentConfigDto { export class UpdateAgentConfigDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -91,7 +165,33 @@ export class UpdateAgentConfigDto {
@IsArray() @IsArray()
skills?: string[] | null; skills?: string[] | null;
/**
* General config blob. May include `capabilities` (AgentCapabilitiesDto)
* for agent specialization declarations (M4-011).
*/
@IsOptional() @IsOptional()
@IsObject() @IsObject()
config?: Record<string, unknown> | null; config?: Record<string, unknown> | null;
// ─── Capability shorthand fields (M4-011) ──────────────────────────────────
@IsOptional()
@IsArray()
@IsString({ each: true })
domains?: string[] | null;
@IsOptional()
@IsString()
@MaxLength(255)
preferredModel?: string | null;
@IsOptional()
@IsString()
@MaxLength(255)
preferredProvider?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
toolSets?: string[] | null;
} }

View File

@@ -19,6 +19,53 @@ import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import { CreateAgentConfigDto, UpdateAgentConfigDto } from './agent-config.dto.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<string, unknown> | undefined {
const hasAny =
dto.domains !== undefined ||
dto.preferredModel !== undefined ||
dto.preferredProvider !== undefined ||
dto.toolSets !== undefined;
if (!hasAny) return undefined;
const cap: Record<string, unknown> = {};
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<string, unknown> | null | undefined,
capabilities: Record<string, unknown> | undefined,
): Record<string, unknown> | 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<string, unknown>)
: {};
return {
...base,
capabilities: { ...existingCap, ...capabilities },
};
}
@Controller('api/agents') @Controller('api/agents')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
export class AgentConfigsController { export class AgentConfigsController {
@@ -41,10 +88,22 @@ export class AgentConfigsController {
@Post() @Post()
async create(@Body() dto: CreateAgentConfigDto, @CurrentUser() user: { id: string }) { 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({ return this.brain.agents.create({
...dto, name: dto.name,
ownerId: user.id, provider: dto.provider,
model: dto.model,
status: dto.status,
projectId: dto.projectId,
systemPrompt: dto.systemPrompt,
allowedTools: dto.allowedTools,
skills: dto.skills,
isSystem: false, isSystem: false,
config,
ownerId: user.id,
}); });
} }
@@ -63,10 +122,32 @@ export class AgentConfigsController {
throw new ForbiddenException('Agent does not belong to the current user'); 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<string, unknown> | null | undefined);
const config = mergeCapabilities(baseConfig ?? undefined, capabilities);
// Pass ownerId for user agents so the repo WHERE clause enforces ownership. // 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. // For system agents (admin path) pass undefined so the WHERE matches only on id.
const ownerId = agent.isSystem ? undefined : user.id; const ownerId = agent.isSystem ? undefined : user.id;
const updated = await this.brain.agents.update(id, dto, ownerId); 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'); if (!updated) throw new NotFoundException('Agent not found');
return updated; return updated;
} }

View File

@@ -3,10 +3,12 @@ import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js'; import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js';
import { RoutingService } from './routing.service.js'; import { RoutingService } from './routing.service.js';
import { RoutingEngineService } from './routing/routing-engine.service.js';
import { SkillLoaderService } from './skill-loader.service.js'; import { SkillLoaderService } from './skill-loader.service.js';
import { ProvidersController } from './providers.controller.js'; import { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js'; import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js'; import { AgentConfigsController } from './agent-configs.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { CoordModule } from '../coord/coord.module.js'; import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js'; import { SkillsModule } from '../skills/skills.module.js';
@@ -19,15 +21,17 @@ import { GCModule } from '../gc/gc.module.js';
ProviderService, ProviderService,
ProviderCredentialsService, ProviderCredentialsService,
RoutingService, RoutingService,
RoutingEngineService,
SkillLoaderService, SkillLoaderService,
AgentService, AgentService,
], ],
controllers: [ProvidersController, SessionsController, AgentConfigsController], controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
exports: [ exports: [
AgentService, AgentService,
ProviderService, ProviderService,
ProviderCredentialsService, ProviderCredentialsService,
RoutingService, RoutingService,
RoutingEngineService,
SkillLoaderService, SkillLoaderService,
], ],
}) })

View File

@@ -0,0 +1,234 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { routingRules, type Db, and, asc, eq, or, inArray } from '@mosaic/db';
import { DB } from '../../database/database.module.js';
import { AuthGuard } from '../../auth/auth.guard.js';
import { CurrentUser } from '../../auth/current-user.decorator.js';
import {
CreateRoutingRuleDto,
UpdateRoutingRuleDto,
ReorderRoutingRulesDto,
} from './routing.dto.js';
@Controller('api/routing/rules')
@UseGuards(AuthGuard)
export class RoutingController {
constructor(@Inject(DB) private readonly db: Db) {}
/**
* GET /api/routing/rules
* List all rules visible to the authenticated user:
* - All system rules
* - User's own rules
* Ordered by priority ascending (lower number = higher priority).
*/
@Get()
async list(@CurrentUser() user: { id: string }) {
const rows = await this.db
.select()
.from(routingRules)
.where(
or(
eq(routingRules.scope, 'system'),
and(eq(routingRules.scope, 'user'), eq(routingRules.userId, user.id)),
),
)
.orderBy(asc(routingRules.priority));
return rows;
}
/**
* GET /api/routing/rules/effective
* Return the merged rule set in priority order.
* User-scoped rules are checked before system rules at the same priority
* (achieved by ordering: priority ASC, then scope='user' first).
*/
@Get('effective')
async effective(@CurrentUser() user: { id: string }) {
const rows = await this.db
.select()
.from(routingRules)
.where(
and(
eq(routingRules.enabled, true),
or(
eq(routingRules.scope, 'system'),
and(eq(routingRules.scope, 'user'), eq(routingRules.userId, user.id)),
),
),
)
.orderBy(asc(routingRules.priority));
// For rules with the same priority: user rules beat system rules.
// Group by priority then stable-sort each group: user before system.
const grouped = new Map<number, typeof rows>();
for (const row of rows) {
const bucket = grouped.get(row.priority) ?? [];
bucket.push(row);
grouped.set(row.priority, bucket);
}
const effective: typeof rows = [];
for (const [, bucket] of [...grouped.entries()].sort(([a], [b]) => a - b)) {
// user-scoped rules first within the same priority bucket
const userRules = bucket.filter((r) => r.scope === 'user');
const systemRules = bucket.filter((r) => r.scope === 'system');
effective.push(...userRules, ...systemRules);
}
return effective;
}
/**
* POST /api/routing/rules
* Create a new routing rule. Scope is forced to 'user' (users cannot create
* system rules). The authenticated user's ID is attached automatically.
*/
@Post()
async create(@Body() dto: CreateRoutingRuleDto, @CurrentUser() user: { id: string }) {
const [created] = await this.db
.insert(routingRules)
.values({
name: dto.name,
priority: dto.priority,
scope: 'user',
userId: user.id,
conditions: dto.conditions as unknown as Record<string, unknown>[],
action: dto.action as unknown as Record<string, unknown>,
enabled: dto.enabled ?? true,
})
.returning();
return created;
}
/**
* PATCH /api/routing/rules/reorder
* Reassign priorities so that the order of `ruleIds` reflects ascending
* priority (index 0 = priority 0, index 1 = priority 1, …).
* Only the authenticated user's own rules can be reordered.
*/
@Patch('reorder')
async reorder(@Body() dto: ReorderRoutingRulesDto, @CurrentUser() user: { id: string }) {
// Verify all supplied IDs belong to this user
const owned = await this.db
.select({ id: routingRules.id })
.from(routingRules)
.where(
and(
inArray(routingRules.id, dto.ruleIds),
eq(routingRules.scope, 'user'),
eq(routingRules.userId, user.id),
),
);
const ownedIds = new Set(owned.map((r) => r.id));
const unowned = dto.ruleIds.filter((id) => !ownedIds.has(id));
if (unowned.length > 0) {
throw new ForbiddenException(
`Cannot reorder rules that do not belong to you: ${unowned.join(', ')}`,
);
}
// Apply new priorities in transaction
const updates = await this.db.transaction(async (tx) => {
const results = [];
for (let i = 0; i < dto.ruleIds.length; i++) {
const [updated] = await tx
.update(routingRules)
.set({ priority: i, updatedAt: new Date() })
.where(and(eq(routingRules.id, dto.ruleIds[i]!), eq(routingRules.userId, user.id)))
.returning();
if (updated) results.push(updated);
}
return results;
});
return updates;
}
/**
* PATCH /api/routing/rules/:id
* Update a user-owned rule. System rules cannot be modified by regular users.
*/
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateRoutingRuleDto,
@CurrentUser() user: { id: string },
) {
const [existing] = await this.db.select().from(routingRules).where(eq(routingRules.id, id));
if (!existing) throw new NotFoundException('Routing rule not found');
if (existing.scope === 'system') {
throw new ForbiddenException('System routing rules cannot be modified');
}
if (existing.userId !== user.id) {
throw new ForbiddenException('Routing rule does not belong to the current user');
}
const updatePayload: Partial<typeof routingRules.$inferInsert> = {
updatedAt: new Date(),
};
if (dto.name !== undefined) updatePayload.name = dto.name;
if (dto.priority !== undefined) updatePayload.priority = dto.priority;
if (dto.conditions !== undefined)
updatePayload.conditions = dto.conditions as unknown as Record<string, unknown>[];
if (dto.action !== undefined)
updatePayload.action = dto.action as unknown as Record<string, unknown>;
if (dto.enabled !== undefined) updatePayload.enabled = dto.enabled;
const [updated] = await this.db
.update(routingRules)
.set(updatePayload)
.where(and(eq(routingRules.id, id), eq(routingRules.userId, user.id)))
.returning();
if (!updated) throw new NotFoundException('Routing rule not found');
return updated;
}
/**
* DELETE /api/routing/rules/:id
* Delete a user-owned routing rule. System rules cannot be deleted.
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const [existing] = await this.db.select().from(routingRules).where(eq(routingRules.id, id));
if (!existing) throw new NotFoundException('Routing rule not found');
if (existing.scope === 'system') {
throw new ForbiddenException('System routing rules cannot be deleted');
}
if (existing.userId !== user.id) {
throw new ForbiddenException('Routing rule does not belong to the current user');
}
const [deleted] = await this.db
.delete(routingRules)
.where(and(eq(routingRules.id, id), eq(routingRules.userId, user.id)))
.returning();
if (!deleted) throw new NotFoundException('Routing rule not found');
}
}

View File

@@ -0,0 +1,135 @@
import {
IsArray,
IsBoolean,
IsInt,
IsIn,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateNested,
ArrayNotEmpty,
} from 'class-validator';
import { Type } from 'class-transformer';
// ─── Condition DTO ────────────────────────────────────────────────────────────
const conditionFields = [
'taskType',
'complexity',
'domain',
'costTier',
'requiredCapabilities',
] as const;
const conditionOperators = ['eq', 'in', 'includes'] as const;
export class RoutingConditionDto {
@IsString()
@IsIn(conditionFields)
field!: (typeof conditionFields)[number];
@IsString()
@IsIn(conditionOperators)
operator!: (typeof conditionOperators)[number];
// value can be string or string[] — keep as unknown and validate at runtime
value!: string | string[];
}
// ─── Action DTO ───────────────────────────────────────────────────────────────
export class RoutingActionDto {
@IsString()
@MaxLength(255)
provider!: string;
@IsString()
@MaxLength(255)
model!: string;
@IsOptional()
@IsUUID()
agentConfigId?: string;
@IsOptional()
@IsString()
@MaxLength(50_000)
systemPromptOverride?: string;
@IsOptional()
@IsArray()
toolAllowlist?: string[];
}
// ─── Create DTO ───────────────────────────────────────────────────────────────
const scopeValues = ['system', 'user'] as const;
export class CreateRoutingRuleDto {
@IsString()
@MaxLength(255)
name!: string;
@IsInt()
@Min(0)
priority!: number;
@IsOptional()
@IsIn(scopeValues)
scope?: 'system' | 'user';
@IsArray()
@ValidateNested({ each: true })
@Type(() => RoutingConditionDto)
conditions!: RoutingConditionDto[];
@IsObject()
@ValidateNested()
@Type(() => RoutingActionDto)
action!: RoutingActionDto;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
// ─── Update DTO ───────────────────────────────────────────────────────────────
export class UpdateRoutingRuleDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsInt()
@Min(0)
priority?: number;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RoutingConditionDto)
conditions?: RoutingConditionDto[];
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => RoutingActionDto)
action?: RoutingActionDto;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
// ─── Reorder DTO ──────────────────────────────────────────────────────────────
export class ReorderRoutingRulesDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID(undefined, { each: true })
ruleIds!: string[];
}

View File

@@ -138,30 +138,56 @@ export class CommandExecutorService {
args: string | null, args: string | null,
conversationId: string, conversationId: string,
): Promise<SlashCommandResultPayload> { ): Promise<SlashCommandResultPayload> {
if (!args) { if (!args || args.trim().length === 0) {
// Show current override or usage hint
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
if (currentOverride) {
return {
command: 'model',
conversationId,
success: true,
message: `Current model override: "${currentOverride}". Use /model <name> to change or /model clear to reset.`,
};
}
return { return {
command: 'model', command: 'model',
conversationId, conversationId,
success: true, success: true,
message: 'Usage: /model <model-name>', message:
'Usage: /model <model-name> — sets a per-session model override (bypasses routing). Use /model clear to reset.',
}; };
} }
// Update agent session model if session is active
// For now, acknowledge the request — full wiring done in P8-012 const modelName = args.trim();
// /model clear removes the override and re-enables automatic routing
if (modelName === 'clear') {
this.chatGateway?.setModelOverride(conversationId, null);
return {
command: 'model',
conversationId,
success: true,
message: 'Model override cleared. Automatic routing will be used for new sessions.',
};
}
// Set the sticky per-session override (M4-007)
this.chatGateway?.setModelOverride(conversationId, modelName);
const session = this.agentService.getSession(conversationId); const session = this.agentService.getSession(conversationId);
if (!session) { if (!session) {
return { return {
command: 'model', command: 'model',
conversationId, conversationId,
success: true, success: true,
message: `Model switch to "${args}" requested. No active session for this conversation.`, message: `Model override set to "${modelName}". Will apply when a new session starts for this conversation.`,
}; };
} }
return { return {
command: 'model', command: 'model',
conversationId, conversationId,
success: true, success: true,
message: `Model switch to "${args}" requested.`, message: `Model override set to "${modelName}". The override is active for this conversation and will be used on the next message if a new session is needed.`,
}; };
} }