feat(M4-009,M4-010,M4-011): routing rules CRUD, per-user overrides, agent capabilities (#320)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #320.
This commit is contained in:
@@ -11,6 +11,51 @@ import {
|
||||
|
||||
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 {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@@ -49,11 +94,40 @@ export class CreateAgentConfigDto {
|
||||
@IsBoolean()
|
||||
isSystem?: boolean;
|
||||
|
||||
/**
|
||||
* General config blob. May include `capabilities` (AgentCapabilitiesDto)
|
||||
* for agent specialization declarations (M4-011).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
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 {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -91,7 +165,33 @@ export class UpdateAgentConfigDto {
|
||||
@IsArray()
|
||||
skills?: string[] | null;
|
||||
|
||||
/**
|
||||
* General config blob. May include `capabilities` (AgentCapabilitiesDto)
|
||||
* for agent specialization declarations (M4-011).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,53 @@ 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<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')
|
||||
@UseGuards(AuthGuard)
|
||||
export class AgentConfigsController {
|
||||
@@ -41,10 +88,22 @@ export class AgentConfigsController {
|
||||
|
||||
@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({
|
||||
...dto,
|
||||
ownerId: user.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,
|
||||
isSystem: false,
|
||||
config,
|
||||
ownerId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,10 +122,32 @@ export class AgentConfigsController {
|
||||
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.
|
||||
// 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, 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');
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { AgentService } from './agent.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||
import { RoutingService } from './routing.service.js';
|
||||
import { RoutingEngineService } from './routing/routing-engine.service.js';
|
||||
import { SkillLoaderService } from './skill-loader.service.js';
|
||||
import { ProvidersController } from './providers.controller.js';
|
||||
import { SessionsController } from './sessions.controller.js';
|
||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||
import { RoutingController } from './routing/routing.controller.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { SkillsModule } from '../skills/skills.module.js';
|
||||
@@ -19,15 +21,17 @@ import { GCModule } from '../gc/gc.module.js';
|
||||
ProviderService,
|
||||
ProviderCredentialsService,
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
AgentService,
|
||||
],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||
exports: [
|
||||
AgentService,
|
||||
ProviderService,
|
||||
ProviderCredentialsService,
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
],
|
||||
})
|
||||
|
||||
234
apps/gateway/src/agent/routing/routing.controller.ts
Normal file
234
apps/gateway/src/agent/routing/routing.controller.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
135
apps/gateway/src/agent/routing/routing.dto.ts
Normal file
135
apps/gateway/src/agent/routing/routing.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -13,12 +13,18 @@ import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import type { SetThinkingPayload, SlashCommandPayload, SystemReloadPayload } from '@mosaic/types';
|
||||
import type {
|
||||
SetThinkingPayload,
|
||||
SlashCommandPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
} from '@mosaic/types';
|
||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||
@@ -33,8 +39,16 @@ interface ClientSession {
|
||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
||||
/** Tool calls in-flight (started but not ended yet). */
|
||||
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
||||
/** Last routing decision made for this session (M4-008) */
|
||||
lastRoutingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-conversation model overrides set via /model command (M4-007).
|
||||
* Keyed by conversationId, value is the model name to use.
|
||||
*/
|
||||
const modelOverrides = new Map<string, string>();
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||
@@ -54,6 +68,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -97,15 +112,50 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
||||
|
||||
// Ensure agent session exists for this conversation
|
||||
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||
|
||||
// Determine provider/model via routing engine or per-session /model override (M4-012 / M4-007)
|
||||
let resolvedProvider = data.provider;
|
||||
let resolvedModelId = data.modelId;
|
||||
|
||||
const modelOverride = modelOverrides.get(conversationId);
|
||||
if (modelOverride) {
|
||||
// /model override bypasses routing engine (M4-007)
|
||||
resolvedModelId = modelOverride;
|
||||
this.logger.log(
|
||||
`Using /model override "${modelOverride}" for conversation=${conversationId}`,
|
||||
);
|
||||
} else if (!resolvedProvider && !resolvedModelId) {
|
||||
// No explicit provider/model from client — use routing engine (M4-012)
|
||||
try {
|
||||
const routingDecision = await this.routingEngine.resolve(data.content, userId);
|
||||
resolvedProvider = routingDecision.provider;
|
||||
resolvedModelId = routingDecision.model;
|
||||
sessionRoutingDecision = {
|
||||
model: routingDecision.model,
|
||||
provider: routingDecision.provider,
|
||||
ruleName: routingDecision.ruleName,
|
||||
reason: routingDecision.reason,
|
||||
};
|
||||
this.logger.log(
|
||||
`Routing decision for conversation=${conversationId}: ${routingDecision.provider}/${routingDecision.model} (rule="${routingDecision.ruleName}")`,
|
||||
);
|
||||
} catch (routingErr) {
|
||||
this.logger.warn(
|
||||
`Routing engine failed for conversation=${conversationId}, using defaults`,
|
||||
routingErr instanceof Error ? routingErr.message : String(routingErr),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
provider: data.provider,
|
||||
modelId: data.modelId,
|
||||
provider: resolvedProvider,
|
||||
modelId: resolvedModelId,
|
||||
agentConfigId: data.agentId,
|
||||
userId,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
@@ -167,18 +217,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
this.relayEvent(client, conversationId, event);
|
||||
});
|
||||
|
||||
// Preserve routing decision from the existing client session if we didn't get a new one
|
||||
const prevClientSession = this.clientSessions.get(client.id);
|
||||
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
|
||||
|
||||
this.clientSessions.set(client.id, {
|
||||
conversationId,
|
||||
cleanup,
|
||||
assistantText: '',
|
||||
toolCalls: [],
|
||||
pendingToolCalls: new Map(),
|
||||
lastRoutingDecision: routingDecisionToStore,
|
||||
});
|
||||
|
||||
// Track channel connection
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||
|
||||
// Send session info so the client knows the model/provider
|
||||
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
||||
{
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (agentSession) {
|
||||
@@ -189,6 +244,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
modelId: agentSession.modelId,
|
||||
thinkingLevel: piSession.thinkingLevel,
|
||||
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||
...(routingDecisionToStore ? { routingDecision: routingDecisionToStore } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -263,6 +319,28 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a per-conversation model override (M4-007).
|
||||
* When set, the routing engine is bypassed and the specified model is used.
|
||||
* Pass null to clear the override and resume automatic routing.
|
||||
*/
|
||||
setModelOverride(conversationId: string, modelName: string | null): void {
|
||||
if (modelName) {
|
||||
modelOverrides.set(conversationId, modelName);
|
||||
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
||||
} else {
|
||||
modelOverrides.delete(conversationId);
|
||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the active model override for a conversation, or undefined if none.
|
||||
*/
|
||||
getModelOverride(conversationId: string): string | undefined {
|
||||
return modelOverrides.get(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a conversation record exists in the DB.
|
||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||
|
||||
@@ -138,30 +138,56 @@ export class CommandExecutorService {
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): 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 {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
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);
|
||||
if (!session) {
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
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 {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -403,6 +403,7 @@ export function TuiApp({
|
||||
providerName={socket.providerName}
|
||||
thinkingLevel={socket.thinkingLevel}
|
||||
conversationId={socket.conversationId}
|
||||
routingDecision={socket.routingDecision}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RoutingDecisionInfo } from '@mosaic/types';
|
||||
import type { TokenUsage } from '../hooks/use-socket.js';
|
||||
import type { GitInfo } from '../hooks/use-git-info.js';
|
||||
|
||||
@@ -12,6 +13,8 @@ export interface BottomBarProps {
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
conversationId: string | undefined;
|
||||
/** Routing decision info for transparency display (M4-008) */
|
||||
routingDecision?: RoutingDecisionInfo | null;
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
@@ -38,6 +41,7 @@ export function BottomBar({
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
conversationId,
|
||||
routingDecision,
|
||||
}: BottomBarProps) {
|
||||
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
||||
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||
@@ -120,6 +124,15 @@ export function BottomBar({
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
|
||||
{routingDecision && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Routed: {routingDecision.model} ({routingDecision.reason})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
CommandManifestPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
RoutingDecisionInfo,
|
||||
} from '@mosaic/types';
|
||||
import { commandRegistry } from '../commands/index.js';
|
||||
|
||||
@@ -66,6 +67,8 @@ export interface UseSocketReturn {
|
||||
providerName: string | null;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: string[];
|
||||
/** Last routing decision received from the gateway (M4-008) */
|
||||
routingDecision: RoutingDecisionInfo | null;
|
||||
sendMessage: (content: string) => void;
|
||||
addSystemMessage: (content: string) => void;
|
||||
setThinkingLevel: (level: string) => void;
|
||||
@@ -109,6 +112,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
||||
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
||||
const [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<TypedSocket | null>(null);
|
||||
@@ -154,6 +158,10 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
setModelName(data.modelId);
|
||||
setThinkingLevelState(data.thinkingLevel);
|
||||
setAvailableThinkingLevels(data.availableThinkingLevels);
|
||||
// Update routing decision if provided (M4-008)
|
||||
if (data.routingDecision) {
|
||||
setRoutingDecision(data.routingDecision);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('agent:start', () => {
|
||||
@@ -319,6 +327,7 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||
providerName,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
routingDecision,
|
||||
sendMessage,
|
||||
addSystemMessage,
|
||||
setThinkingLevel,
|
||||
|
||||
@@ -74,6 +74,14 @@ export interface ChatMessagePayload {
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/** Routing decision summary included in session:info for transparency */
|
||||
export interface RoutingDecisionInfo {
|
||||
model: string;
|
||||
provider: string;
|
||||
ruleName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Session info pushed when session is created or model changes */
|
||||
export interface SessionInfoPayload {
|
||||
conversationId: string;
|
||||
@@ -81,6 +89,8 @@ export interface SessionInfoPayload {
|
||||
modelId: string;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: string[];
|
||||
/** Present when automatic routing determined the model for this session */
|
||||
routingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
|
||||
/** Client request to change thinking level */
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
ToolEndPayload,
|
||||
SessionUsagePayload,
|
||||
SessionInfoPayload,
|
||||
RoutingDecisionInfo,
|
||||
SetThinkingPayload,
|
||||
ErrorPayload,
|
||||
ChatMessagePayload,
|
||||
|
||||
Reference in New Issue
Block a user