- 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>
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
import type { QueueHandle } from '@mosaic/queue';
|
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
|
import { AgentService } from '../agent/agent.service.js';
|
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
|
import { ReloadService } from '../reload/reload.service.js';
|
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
|
import { CommandRegistryService } from './command-registry.service.js';
|
|
|
|
@Injectable()
|
|
export class CommandExecutorService {
|
|
private readonly logger = new Logger(CommandExecutorService.name);
|
|
|
|
constructor(
|
|
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
|
@Inject(AgentService) private readonly agentService: AgentService,
|
|
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
|
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
|
@Optional()
|
|
@Inject(forwardRef(() => ReloadService))
|
|
private readonly reloadService: ReloadService | null,
|
|
@Optional()
|
|
@Inject(forwardRef(() => ChatGateway))
|
|
private readonly chatGateway: ChatGateway | null,
|
|
) {}
|
|
|
|
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
|
const { command, args, conversationId } = payload;
|
|
|
|
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
|
if (!def) {
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: false,
|
|
message: `Unknown command: /${command}`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
switch (command) {
|
|
case 'model':
|
|
return await this.handleModel(args ?? null, conversationId);
|
|
case 'thinking':
|
|
return await this.handleThinking(args ?? null, conversationId);
|
|
case 'system':
|
|
return await this.handleSystem(args ?? null, conversationId);
|
|
case 'new':
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: true,
|
|
message: 'Start a new conversation by selecting New Conversation.',
|
|
};
|
|
case 'clear':
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: true,
|
|
message: 'Conversation display cleared.',
|
|
};
|
|
case 'compact':
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: true,
|
|
message: 'Context compaction requested.',
|
|
};
|
|
case 'retry':
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: true,
|
|
message: 'Retry last message requested.',
|
|
};
|
|
case 'gc': {
|
|
// Admin-only: system-wide GC sweep across all sessions
|
|
const result = await this.sessionGC.sweepOrphans();
|
|
return {
|
|
command: 'gc',
|
|
success: true,
|
|
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
case 'agent':
|
|
return await this.handleAgent(args ?? null, conversationId);
|
|
case 'provider':
|
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
|
case 'mission':
|
|
return await this.handleMission(args ?? null, conversationId, userId);
|
|
case 'prdy':
|
|
return {
|
|
command: 'prdy',
|
|
success: true,
|
|
message:
|
|
'PRD wizard: run `mosaic prdy` in your project workspace to create or update a PRD.',
|
|
conversationId,
|
|
};
|
|
case 'tools':
|
|
return await this.handleTools(conversationId, userId);
|
|
case 'reload': {
|
|
if (!this.reloadService) {
|
|
return {
|
|
command: 'reload',
|
|
conversationId,
|
|
success: false,
|
|
message: 'ReloadService is not available.',
|
|
};
|
|
}
|
|
const reloadResult = await this.reloadService.reload('command');
|
|
this.chatGateway?.broadcastReload(reloadResult);
|
|
return {
|
|
command: 'reload',
|
|
success: true,
|
|
message: reloadResult.message,
|
|
conversationId,
|
|
};
|
|
}
|
|
default:
|
|
return {
|
|
command,
|
|
conversationId,
|
|
success: false,
|
|
message: `Command /${command} is not yet implemented.`,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(`Command /${command} failed: ${err}`);
|
|
return { command, conversationId, success: false, message: String(err) };
|
|
}
|
|
}
|
|
|
|
private async handleModel(
|
|
args: string | null,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
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> — sets a per-session model override (bypasses routing). Use /model clear to reset.',
|
|
};
|
|
}
|
|
|
|
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 override set to "${modelName}". Will apply when a new session starts for this conversation.`,
|
|
};
|
|
}
|
|
return {
|
|
command: 'model',
|
|
conversationId,
|
|
success: true,
|
|
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.`,
|
|
};
|
|
}
|
|
|
|
private async handleThinking(
|
|
args: string | null,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
const level = args?.toLowerCase();
|
|
if (!level || !['none', 'low', 'medium', 'high', 'auto'].includes(level)) {
|
|
return {
|
|
command: 'thinking',
|
|
conversationId,
|
|
success: true,
|
|
message: 'Usage: /thinking <none|low|medium|high|auto>',
|
|
};
|
|
}
|
|
return {
|
|
command: 'thinking',
|
|
conversationId,
|
|
success: true,
|
|
message: `Thinking level set to "${level}".`,
|
|
};
|
|
}
|
|
|
|
private async handleSystem(
|
|
args: string | null,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args || args.trim().length === 0) {
|
|
// Clear the override when called with no args
|
|
await this.systemOverride.clear(conversationId);
|
|
return {
|
|
command: 'system',
|
|
conversationId,
|
|
success: true,
|
|
message: 'Session system prompt override cleared.',
|
|
};
|
|
}
|
|
|
|
await this.systemOverride.set(conversationId, args.trim());
|
|
return {
|
|
command: 'system',
|
|
conversationId,
|
|
success: true,
|
|
message: `Session system prompt override set (expires in 5 minutes of inactivity).`,
|
|
};
|
|
}
|
|
|
|
private async handleAgent(
|
|
args: string | null,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args) {
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: 'Usage: /agent <agent-id> to switch, or /agent list to see available agents.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
if (args === 'list') {
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: 'Agent listing: use the web dashboard for full agent management.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
// Switch agent — stub for now (full implementation in P8-015)
|
|
return {
|
|
command: 'agent',
|
|
success: true,
|
|
message: `Agent switch to "${args}" requested. Restart conversation to apply.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
private async handleProvider(
|
|
args: string | null,
|
|
userId: string,
|
|
conversationId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args) {
|
|
return {
|
|
command: 'provider',
|
|
success: true,
|
|
message: 'Usage: /provider list | /provider login <name> | /provider logout <name>',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
const spaceIdx = args.indexOf(' ');
|
|
const subcommand = spaceIdx >= 0 ? args.slice(0, spaceIdx) : args;
|
|
const providerName = spaceIdx >= 0 ? args.slice(spaceIdx + 1).trim() : '';
|
|
|
|
switch (subcommand) {
|
|
case 'list':
|
|
return {
|
|
command: 'provider',
|
|
success: true,
|
|
message: 'Use the web dashboard to manage providers.',
|
|
conversationId,
|
|
};
|
|
|
|
case 'login': {
|
|
if (!providerName) {
|
|
return {
|
|
command: 'provider',
|
|
success: false,
|
|
message: 'Usage: /provider login <provider-name>',
|
|
conversationId,
|
|
};
|
|
}
|
|
const pollToken = crypto.randomUUID();
|
|
const key = `mosaic:auth:poll:${pollToken}`;
|
|
// Store pending state in Valkey (TTL 5 minutes)
|
|
await this.redis.set(
|
|
key,
|
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
|
'EX',
|
|
300,
|
|
);
|
|
// In production this would construct an OAuth URL
|
|
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
|
return {
|
|
command: 'provider',
|
|
success: true,
|
|
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
|
conversationId,
|
|
data: { loginUrl, pollToken, provider: providerName },
|
|
};
|
|
}
|
|
|
|
case 'logout': {
|
|
if (!providerName) {
|
|
return {
|
|
command: 'provider',
|
|
success: false,
|
|
message: 'Usage: /provider logout <provider-name>',
|
|
conversationId,
|
|
};
|
|
}
|
|
return {
|
|
command: 'provider',
|
|
success: true,
|
|
message: `Logout from ${providerName}: use the web dashboard to revoke provider tokens.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
default:
|
|
return {
|
|
command: 'provider',
|
|
success: false,
|
|
message: `Unknown subcommand: ${subcommand}. Use list, login, or logout.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async handleMission(
|
|
args: string | null,
|
|
conversationId: string,
|
|
_userId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
if (!args || args === 'status') {
|
|
// TODO: fetch active mission from DB when MissionsService is available
|
|
return {
|
|
command: 'mission',
|
|
success: true,
|
|
message: 'Mission status: use the web dashboard for full mission management.',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
if (args.startsWith('set ')) {
|
|
const missionId = args.slice(4).trim();
|
|
return {
|
|
command: 'mission',
|
|
success: true,
|
|
message: `Mission set to ${missionId}. Session context updated.`,
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: 'mission',
|
|
success: true,
|
|
message: 'Usage: /mission [status|set <id>|list|tasks]',
|
|
conversationId,
|
|
};
|
|
}
|
|
|
|
private async handleTools(
|
|
conversationId: string,
|
|
_userId: string,
|
|
): Promise<SlashCommandResultPayload> {
|
|
// TODO: fetch tool list from active agent session
|
|
return {
|
|
command: 'tools',
|
|
success: true,
|
|
message:
|
|
'Available tools depend on the active agent configuration. Use the web dashboard to configure tool access.',
|
|
conversationId,
|
|
};
|
|
}
|
|
}
|