From b18976a7aad9acc12fb3406443d23b13e88b256d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 23 Mar 2026 00:48:42 +0000 Subject: [PATCH] feat(M4-009,M4-010,M4-011): routing rules CRUD, per-user overrides, agent capabilities (#320) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- apps/gateway/src/agent/agent-config.dto.ts | 100 ++++++++ .../src/agent/agent-configs.controller.ts | 87 ++++++- apps/gateway/src/agent/agent.module.ts | 6 +- .../src/agent/routing/routing.controller.ts | 234 ++++++++++++++++++ apps/gateway/src/agent/routing/routing.dto.ts | 135 ++++++++++ apps/gateway/src/chat/chat.gateway.ts | 86 ++++++- .../src/commands/command-executor.service.ts | 38 ++- packages/cli/src/tui/app.tsx | 1 + .../cli/src/tui/components/bottom-bar.tsx | 13 + packages/cli/src/tui/hooks/use-socket.ts | 9 + packages/types/src/chat/events.ts | 10 + packages/types/src/chat/index.ts | 1 + 12 files changed, 706 insertions(+), 14 deletions(-) create mode 100644 apps/gateway/src/agent/routing/routing.controller.ts create mode 100644 apps/gateway/src/agent/routing/routing.dto.ts diff --git a/apps/gateway/src/agent/agent-config.dto.ts b/apps/gateway/src/agent/agent-config.dto.ts index 3fbbea8..233dfe7 100644 --- a/apps/gateway/src/agent/agent-config.dto.ts +++ b/apps/gateway/src/agent/agent-config.dto.ts @@ -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; + + // ─── 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 | 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; } diff --git a/apps/gateway/src/agent/agent-configs.controller.ts b/apps/gateway/src/agent/agent-configs.controller.ts index b15812e..aa59d81 100644 --- a/apps/gateway/src/agent/agent-configs.controller.ts +++ b/apps/gateway/src/agent/agent-configs.controller.ts @@ -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 | 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 { @@ -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 | 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; } diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index e43667a..94b9fa3 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -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, ], }) diff --git a/apps/gateway/src/agent/routing/routing.controller.ts b/apps/gateway/src/agent/routing/routing.controller.ts new file mode 100644 index 0000000..4870a1f --- /dev/null +++ b/apps/gateway/src/agent/routing/routing.controller.ts @@ -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(); + 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[], + action: dto.action as unknown as Record, + 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 = { + 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[]; + if (dto.action !== undefined) + updatePayload.action = dto.action as unknown as Record; + 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'); + } +} diff --git a/apps/gateway/src/agent/routing/routing.dto.ts b/apps/gateway/src/agent/routing/routing.dto.ts new file mode 100644 index 0000000..ccd9a12 --- /dev/null +++ b/apps/gateway/src/agent/routing/routing.dto.ts @@ -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[]; +} diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 0795e85..7e24b12 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -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; + /** 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(); + @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 diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 6132df9..e6def05 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -138,30 +138,56 @@ export class CommandExecutorService { args: string | null, conversationId: string, ): Promise { - 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 to change or /model clear to reset.`, + }; + } return { command: 'model', conversationId, success: true, - message: 'Usage: /model ', + message: + 'Usage: /model — 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.`, }; } diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 2c556a8..d81cab6 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -403,6 +403,7 @@ export function TuiApp({ providerName={socket.providerName} thinkingLevel={socket.thinkingLevel} conversationId={socket.conversationId} + routingDecision={socket.routingDecision} /> ); diff --git a/packages/cli/src/tui/components/bottom-bar.tsx b/packages/cli/src/tui/components/bottom-bar.tsx index 53c80fe..f875501 100644 --- a/packages/cli/src/tui/components/bottom-bar.tsx +++ b/packages/cli/src/tui/components/bottom-bar.tsx @@ -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({ + + {/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */} + {routingDecision && ( + + + Routed: {routingDecision.model} ({routingDecision.reason}) + + + )} ); } diff --git a/packages/cli/src/tui/hooks/use-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts index 08ca792..0635e01 100644 --- a/packages/cli/src/tui/hooks/use-socket.ts +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -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(null); const [thinkingLevel, setThinkingLevelState] = useState('off'); const [availableThinkingLevels, setAvailableThinkingLevels] = useState([]); + const [routingDecision, setRoutingDecision] = useState(null); const [connectionError, setConnectionError] = useState(null); const socketRef = useRef(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, diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 5d6bcf2..313bb6c 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -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 */ diff --git a/packages/types/src/chat/index.ts b/packages/types/src/chat/index.ts index 7d039a7..a8440ad 100644 --- a/packages/types/src/chat/index.ts +++ b/packages/types/src/chat/index.ts @@ -9,6 +9,7 @@ export type { ToolEndPayload, SessionUsagePayload, SessionInfoPayload, + RoutingDecisionInfo, SetThinkingPayload, ErrorPayload, ChatMessagePayload,