diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index 0cb9af5..a0cc8b8 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -16,8 +16,10 @@ import { } from '../../agents/spawner.js'; import { listAgentTypes, getAgentDefinition } from '../../agents/registry.js'; import { getConfig } from '../../utils/config.js'; +import { getSmartDispatcher } from '../../tasks/smart-dispatcher.js'; import { readFileSync, existsSync } from 'node:fs'; import { runAgentWatch, type AgentWatchOptions } from './agent-watch.js'; +import * as readline from 'node:readline'; export function createAgentCommand(): Command { const command = new Command('agent') @@ -375,5 +377,114 @@ export function createAgentCommand(): Command { } }); + // auto subcommand - automatically select agent type and run + command + .command('auto') + .description('Automatically select the best agent type for a task and run it') + .argument('', 'Task description') + .option('--dry-run', 'Only show agent selection, do not execute') + .option('--confirm', 'Ask for confirmation before executing') + .option('--provider ', 'Provider to use (claude-code, gemini-cli, codex, anthropic, openai, ollama)') + .option('--model ', 'Model to use') + .option('--context ', 'Additional context or @file to read from file') + .action(async (description, options) => { + const { + dryRun, + confirm, + provider, + model, + context: rawContext, + } = options as { + dryRun?: boolean; + confirm?: boolean; + provider?: string; + model?: string; + context?: string; + }; + + try { + const config = getConfig(); + const dispatcher = getSmartDispatcher(config); + + if (!dispatcher.isEnabled()) { + console.error('Error: Smart dispatcher is not enabled or no provider configured'); + process.exit(1); + } + + console.log('Analyzing task description...\n'); + + const result = await dispatcher.dispatch(description); + + if (!result.success || !result.decision) { + console.error(`Error: ${result.error ?? 'Failed to dispatch task'}`); + process.exit(1); + } + + const { agentType, confidence, reasoning, cached, latencyMs } = result.decision; + + console.log(`Selected agent: ${agentType}`); + console.log(`Confidence: ${Math.round(confidence * 100)}%`); + console.log(`Reasoning: ${reasoning}`); + if (cached) { + console.log(`(Cached result)`); + } + console.log(`Dispatch latency: ${latencyMs}ms\n`); + + if (dryRun) { + console.log('Dry run - not executing agent.'); + return; + } + + // Ask for confirmation if requested + if (confirm) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const answer = await new Promise((resolve) => { + rl.question('Proceed with this agent? (y/N): ', resolve); + }); + rl.close(); + + if (answer.toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } + } + + // Read context from file if it starts with @ + let context = rawContext; + if (rawContext?.startsWith('@')) { + const filePath = rawContext.slice(1); + if (!existsSync(filePath)) { + console.error(`Error: Context file not found: ${filePath}`); + process.exit(1); + } + context = readFileSync(filePath, 'utf-8'); + } + + console.log(`Running ${agentType} agent...\n`); + + const agentResult = await runAgent(agentType, description, config, { + provider, + model, + context, + }); + + console.log('─'.repeat(60)); + console.log('Response:'); + console.log('─'.repeat(60)); + console.log(agentResult.response); + console.log('─'.repeat(60)); + console.log(`\nAgent: ${agentResult.agentId}`); + console.log(`Model: ${agentResult.model}`); + console.log(`Duration: ${(agentResult.duration / 1000).toFixed(2)}s`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + }); + return command; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c98d6f1..ceae387 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -22,6 +22,7 @@ import { } from './tools/index.js'; import { DriftDetectionService } from '../tasks/drift-detection-service.js'; import { ConsensusService } from '../tasks/consensus-service.js'; +import { SmartDispatcher } from '../tasks/smart-dispatcher.js'; const log = logger.child('mcp'); @@ -39,12 +40,14 @@ export class MCPServer { private config: AgentStackConfig; private driftService: DriftDetectionService; private consensusService: ConsensusService; + private smartDispatcher: SmartDispatcher; constructor(config: AgentStackConfig) { this.config = config; this.memory = new MemoryManager(config); this.driftService = new DriftDetectionService(this.memory.getStore(), config); this.consensusService = new ConsensusService(this.memory.getStore(), config); + this.smartDispatcher = new SmartDispatcher(config); this.server = new Server( { @@ -68,7 +71,7 @@ export class MCPServer { createAgentTools(this.config), createIdentityTools(this.config), createMemoryTools(this.memory), - createTaskTools(this.memory, this.driftService, this.consensusService), + createTaskTools(this.memory, this.driftService, this.consensusService, this.smartDispatcher, this.config), createSessionTools(this.memory), createSystemTools(this.memory, this.config), createGitHubTools(this.config), diff --git a/src/mcp/tools/task-tools.ts b/src/mcp/tools/task-tools.ts index b065cdf..8cc7fe0 100644 --- a/src/mcp/tools/task-tools.ts +++ b/src/mcp/tools/task-tools.ts @@ -7,11 +7,12 @@ import { randomUUID } from 'node:crypto'; import type { MemoryManager } from '../../memory/index.js'; import type { DriftDetectionService } from '../../tasks/drift-detection-service.js'; import type { ConsensusService } from '../../tasks/consensus-service.js'; -import type { TaskRiskLevel, ProposedSubtask } from '../../types.js'; +import type { SmartDispatcher } from '../../tasks/smart-dispatcher.js'; +import type { TaskRiskLevel, ProposedSubtask, AgentStackConfig } from '../../types.js'; // Input schemas const CreateInputSchema = z.object({ - agentType: z.string().min(1).describe('Agent type for this task'), + agentType: z.string().min(1).optional().describe('Agent type for this task (optional - will auto-dispatch if not provided)'), input: z.string().optional().describe('Task input/description'), sessionId: z.string().uuid().optional().describe('Session to associate with'), parentTaskId: z.string().uuid().optional().describe('Parent task ID for drift detection'), @@ -52,33 +53,57 @@ const GetRelationshipsInputSchema = z.object({ export function createTaskTools( memory: MemoryManager, driftService?: DriftDetectionService, - consensusService?: ConsensusService + consensusService?: ConsensusService, + smartDispatcher?: SmartDispatcher, + config?: AgentStackConfig ) { return { task_create: { name: 'task_create', - description: 'Create a new task with optional drift detection and consensus checking', + description: 'Create a new task with optional drift detection and consensus checking. Agent type is auto-detected if not specified.', inputSchema: { type: 'object', properties: { - agentType: { type: 'string', description: 'Agent type for this task' }, + agentType: { type: 'string', description: 'Agent type for this task (optional - auto-dispatched if not provided)' }, input: { type: 'string', description: 'Task input/description' }, sessionId: { type: 'string', description: 'Session to associate with' }, parentTaskId: { type: 'string', description: 'Parent task ID for drift detection' }, riskLevel: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Risk level for consensus checking' }, }, - required: ['agentType'], + required: [], }, handler: async (params: Record) => { const input = CreateInputSchema.parse(params); try { + // Auto-dispatch agent type if not specified + let agentType = input.agentType; + let dispatchInfo: { agentType: string; confidence: number; reasoning: string; cached: boolean } | undefined; + + if (!agentType && input.input && smartDispatcher?.isEnabled()) { + const dispatchResult = await smartDispatcher.dispatch(input.input); + if (dispatchResult.success && dispatchResult.decision) { + agentType = dispatchResult.decision.agentType; + dispatchInfo = { + agentType: dispatchResult.decision.agentType, + confidence: dispatchResult.decision.confidence, + reasoning: dispatchResult.decision.reasoning, + cached: dispatchResult.decision.cached, + }; + } + } + + // Fallback to config default if still no agent type + if (!agentType) { + agentType = config?.smartDispatcher?.fallbackAgentType ?? 'coder'; + } + // Check for drift if service is available and input is provided let driftResult = null; if (driftService && input.input && input.parentTaskId) { driftResult = await driftService.checkDrift( input.input, - input.agentType, + agentType, input.parentTaskId ); @@ -102,7 +127,7 @@ export function createTaskTools( if (consensusService && consensusService.isEnabled()) { // Estimate or use provided risk level const riskLevel: TaskRiskLevel = input.riskLevel || - consensusService.estimateRiskLevel(input.agentType, input.input); + consensusService.estimateRiskLevel(agentType, input.input); // Calculate depth const depth = consensusService.calculateTaskDepth(input.parentTaskId); @@ -119,7 +144,7 @@ export function createTaskTools( const subtaskId = randomUUID(); const proposedSubtask: ProposedSubtask = { id: subtaskId, - agentType: input.agentType, + agentType, input: input.input || '', estimatedRiskLevel: riskLevel, parentTaskId: input.parentTaskId || '', @@ -154,11 +179,11 @@ export function createTaskTools( // Get risk level and depth for task creation const riskLevel: TaskRiskLevel | undefined = input.riskLevel || - (consensusService ? consensusService.estimateRiskLevel(input.agentType, input.input) : undefined); + (consensusService ? consensusService.estimateRiskLevel(agentType, input.input) : undefined); const depth = consensusService?.calculateTaskDepth(input.parentTaskId); const task = memory.createTask( - input.agentType, + agentType, input.input, input.sessionId, { @@ -194,6 +219,7 @@ export function createTaskTools( riskLevel: task.riskLevel, depth: task.depth, }, + dispatch: dispatchInfo, drift: driftResult ? { isDrift: driftResult.isDrift, highestSimilarity: driftResult.highestSimilarity, diff --git a/src/tasks/index.ts b/src/tasks/index.ts index 9dc2399..63e90e4 100644 --- a/src/tasks/index.ts +++ b/src/tasks/index.ts @@ -7,3 +7,9 @@ export { getDriftDetectionService, resetDriftDetectionService, } from './drift-detection-service.js'; + +export { + SmartDispatcher, + getSmartDispatcher, + resetSmartDispatcher, +} from './smart-dispatcher.js'; diff --git a/src/tasks/smart-dispatcher.ts b/src/tasks/smart-dispatcher.ts new file mode 100644 index 0000000..facd83b --- /dev/null +++ b/src/tasks/smart-dispatcher.ts @@ -0,0 +1,407 @@ +/** + * Smart Dispatcher Service + * Uses LLM to automatically select the best agent type based on task description + */ + +import type { + AgentStackConfig, + SmartDispatcherConfig, + DispatchDecision, + LLMProvider, +} from '../types.js'; +import { getProvider, AnthropicProvider } from '../providers/index.js'; +import { logger } from '../utils/logger.js'; + +const log = logger.child('smart-dispatcher'); + +const DEFAULT_CONFIG: SmartDispatcherConfig = { + enabled: true, + cacheEnabled: true, + cacheTTLMs: 3600000, // 1 hour + confidenceThreshold: 0.7, + fallbackAgentType: 'coder', + maxDescriptionLength: 1000, + dispatchModel: 'claude-haiku-4-5-20251001', // Fast & cost-effective, alternatives: claude-sonnet-4-5-20250929, claude-opus-4-5-20251101 +}; + +const SYSTEM_PROMPT = `You are an AI task router. Your job is to analyze a task description and select the most appropriate agent type to handle it. + +Available agents and their capabilities: +- coder: write-code, edit-code, refactor, debug, implement features, fix bugs +- researcher: search-code, analyze-patterns, explore-codebase, find information +- tester: write-tests, run-tests, coverage-analysis, test automation +- reviewer: code-review, security-review, best-practices, quality assurance +- adversarial: break-code, edge-case-analysis, security testing, fault injection +- architect: system-design, technical-decisions, architecture planning +- coordinator: task-decomposition, workflow-management, orchestration +- analyst: data-analysis, performance-profiling, metrics evaluation +- devops: ci-cd-setup, containerization, kubernetes, deployment, infrastructure +- documentation: api-docs, user-guides, tutorials, technical writing +- security-auditor: vulnerability-scanning, compliance, security assessment + +Analyze the task and respond ONLY with a JSON object in this exact format: +{"agentType":"","confidence":<0.0-1.0>,"reasoning":""} + +Do not include any other text, markdown formatting, or code blocks. Just the raw JSON.`; + +interface CacheEntry { + decision: DispatchDecision; + expiresAt: number; +} + +export class SmartDispatcher { + private config: SmartDispatcherConfig; + private appConfig: AgentStackConfig; + private cache: Map; + private provider: LLMProvider | null; + + constructor(appConfig: AgentStackConfig) { + this.config = { ...DEFAULT_CONFIG, ...appConfig.smartDispatcher }; + this.appConfig = appConfig; + this.cache = new Map(); + this.provider = this.createProvider(); + + log.debug('Smart dispatcher initialized', { + enabled: this.config.enabled, + cacheEnabled: this.config.cacheEnabled, + cacheTTLMs: this.config.cacheTTLMs, + }); + } + + /** + * Create the LLM provider for dispatching + * Uses configurable model (defaults to Haiku for low latency) + */ + private createProvider(): LLMProvider | null { + try { + // Try to get Anthropic provider with configured dispatch model + if (this.appConfig.providers.anthropic?.apiKey) { + return new AnthropicProvider( + this.appConfig.providers.anthropic.apiKey, + this.config.dispatchModel + ); + } + + // Fall back to default provider + const provider = getProvider(this.appConfig.providers.default, this.appConfig); + return provider; + } catch (error) { + log.warn('Failed to create dispatch provider', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + /** + * Check if the dispatcher is enabled and operational + */ + isEnabled(): boolean { + return this.config.enabled && this.provider !== null; + } + + /** + * Get the current configuration + */ + getConfig(): SmartDispatcherConfig { + return { ...this.config }; + } + + /** + * Dispatch a task to the appropriate agent type + */ + async dispatch(description: string): Promise<{ + success: boolean; + decision?: DispatchDecision; + error?: string; + }> { + if (!this.isEnabled()) { + return { + success: false, + error: 'Smart dispatcher is not enabled or no provider available', + }; + } + + const startTime = Date.now(); + + try { + // Truncate description if too long + const truncatedDesc = description.slice(0, this.config.maxDescriptionLength); + + // Check cache first + if (this.config.cacheEnabled) { + const cached = this.getCachedDecision(truncatedDesc); + if (cached) { + log.debug('Cache hit for dispatch', { description: truncatedDesc.slice(0, 50) }); + return { + success: true, + decision: { + ...cached, + cached: true, + latencyMs: Date.now() - startTime, + }, + }; + } + } + + // Call LLM to select agent type + const decision = await this.selectAgentType(truncatedDesc); + decision.latencyMs = Date.now() - startTime; + decision.cached = false; + + // Apply confidence threshold + if (decision.confidence < this.config.confidenceThreshold) { + log.debug('Low confidence dispatch, using fallback', { + confidence: decision.confidence, + threshold: this.config.confidenceThreshold, + selectedType: decision.agentType, + fallbackType: this.config.fallbackAgentType, + }); + decision.agentType = this.config.fallbackAgentType; + decision.reasoning = `Low confidence (${decision.confidence.toFixed(2)}), using fallback agent`; + } + + // Cache the decision + if (this.config.cacheEnabled) { + this.cacheDecision(truncatedDesc, decision); + } + + log.info('Task dispatched', { + agentType: decision.agentType, + confidence: decision.confidence, + latencyMs: decision.latencyMs, + }); + + return { success: true, decision }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error('Dispatch failed, using fallback', { error: errorMessage }); + + return { + success: true, + decision: { + agentType: this.config.fallbackAgentType, + confidence: 0, + reasoning: `Dispatch failed: ${errorMessage}. Using fallback agent.`, + cached: false, + latencyMs: Date.now() - startTime, + }, + }; + } + } + + /** + * Select the best agent type for a task description using LLM + */ + async selectAgentType(description: string): Promise { + if (!this.provider) { + throw new Error('No provider available'); + } + + const response = await this.provider.chat( + [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: description }, + ], + { + maxTokens: 100, + temperature: 0, + } + ); + + return this.parseResponse(response.content); + } + + /** + * Parse the LLM response into a DispatchDecision + */ + private parseResponse(content: string): DispatchDecision { + try { + // Try to extract JSON from the response + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('No JSON found in response'); + } + + const parsed = JSON.parse(jsonMatch[0]) as { + agentType?: string; + confidence?: number; + reasoning?: string; + }; + + // Validate required fields + if (!parsed.agentType || typeof parsed.agentType !== 'string') { + throw new Error('Invalid or missing agentType'); + } + + const validAgentTypes = [ + 'coder', 'researcher', 'tester', 'reviewer', 'adversarial', + 'architect', 'coordinator', 'analyst', 'devops', 'documentation', + 'security-auditor', + ]; + + // Normalize agent type + const normalizedType = parsed.agentType.toLowerCase().replace(/[_\s]/g, '-'); + const agentType = validAgentTypes.includes(normalizedType) + ? normalizedType + : this.config.fallbackAgentType; + + return { + agentType, + confidence: typeof parsed.confidence === 'number' + ? Math.max(0, Math.min(1, parsed.confidence)) + : 0.5, + reasoning: typeof parsed.reasoning === 'string' + ? parsed.reasoning + : 'No reasoning provided', + cached: false, + latencyMs: 0, + }; + } catch (error) { + log.warn('Failed to parse LLM response', { + content, + error: error instanceof Error ? error.message : String(error), + }); + + return { + agentType: this.config.fallbackAgentType, + confidence: 0, + reasoning: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`, + cached: false, + latencyMs: 0, + }; + } + } + + /** + * Get a cached decision if available and not expired + */ + private getCachedDecision(description: string): DispatchDecision | null { + const key = this.getCacheKey(description); + const entry = this.cache.get(key); + + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + + return entry.decision; + } + + /** + * Cache a dispatch decision + */ + private cacheDecision(description: string, decision: DispatchDecision): void { + const key = this.getCacheKey(description); + this.cache.set(key, { + decision, + expiresAt: Date.now() + this.config.cacheTTLMs, + }); + + // Clean up old entries periodically + if (this.cache.size > 1000) { + this.cleanCache(); + } + } + + /** + * Generate a cache key from a description using FNV-1a hash + * FNV-1a provides better distribution and fewer collisions than simple hash + */ + private getCacheKey(description: string): string { + // FNV-1a 32-bit hash parameters + const FNV_PRIME = 0x01000193; + const FNV_OFFSET_BASIS = 0x811c9dc5; + + let hash = FNV_OFFSET_BASIS; + for (let i = 0; i < description.length; i++) { + hash ^= description.charCodeAt(i); + hash = Math.imul(hash, FNV_PRIME); + } + + // Convert to unsigned 32-bit and then to hex for readable key + const unsignedHash = hash >>> 0; + return `dispatch:${unsignedHash.toString(16)}`; + } + + /** + * Clean expired cache entries + */ + private cleanCache(): void { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) { + this.cache.delete(key); + } + } + } + + /** + * Clear the entire cache + */ + clearCache(): void { + this.cache.clear(); + log.debug('Dispatch cache cleared'); + } + + /** + * Get cache statistics + */ + getCacheStats(): { size: number; enabled: boolean } { + return { + size: this.cache.size, + enabled: this.config.cacheEnabled, + }; + } +} + +// Singleton instance +let instance: SmartDispatcher | null = null; +let instanceConfig: SmartDispatcherConfig | null = null; + +/** + * Get or create the smart dispatcher instance + */ +export function getSmartDispatcher( + config: AgentStackConfig, + forceNew: boolean = false +): SmartDispatcher { + const newConfig = config.smartDispatcher; + + if (forceNew || !instance || !configEquals(instanceConfig, newConfig)) { + instance = new SmartDispatcher(config); + instanceConfig = newConfig ? { ...newConfig } : null; + } + + return instance; +} + +/** + * Compare two smart dispatcher configs for equality + */ +function configEquals( + a: SmartDispatcherConfig | null | undefined, + b: SmartDispatcherConfig | null | undefined +): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + return ( + a.enabled === b.enabled && + a.cacheEnabled === b.cacheEnabled && + a.cacheTTLMs === b.cacheTTLMs && + a.confidenceThreshold === b.confidenceThreshold && + a.fallbackAgentType === b.fallbackAgentType && + a.maxDescriptionLength === b.maxDescriptionLength && + a.dispatchModel === b.dispatchModel + ); +} + +/** + * Reset the smart dispatcher instance + */ +export function resetSmartDispatcher(): void { + instance = null; + instanceConfig = null; +} diff --git a/src/types.ts b/src/types.ts index 510cdf8..b00dd63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -347,6 +347,7 @@ export interface AgentStackConfig { driftDetection?: DriftDetectionConfig; resourceExhaustion?: ResourceExhaustionConfig; consensus?: ConsensusConfig; + smartDispatcher?: SmartDispatcherConfig; } export interface MemoryConfig { @@ -595,6 +596,25 @@ export interface DriftDetectionEvent { createdAt: Date; } +// Smart Dispatcher types +export interface SmartDispatcherConfig { + enabled: boolean; + cacheEnabled: boolean; + cacheTTLMs: number; + confidenceThreshold: number; + fallbackAgentType: string; + maxDescriptionLength: number; + dispatchModel: string; +} + +export interface DispatchDecision { + agentType: string; + confidence: number; + reasoning: string; + cached: boolean; + latencyMs: number; +} + // Resource Exhaustion types export type ResourceExhaustionPhase = 'normal' | 'warning' | 'intervention' | 'termination'; export type ResourceExhaustionAction = 'allowed' | 'warned' | 'paused' | 'terminated'; diff --git a/src/utils/config.ts b/src/utils/config.ts index 817e5b1..77d51f7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -153,6 +153,17 @@ const ConsensusConfigSchema = z.object({ ]), }); +const SmartDispatcherConfigSchema = z.object({ + enabled: z.boolean().default(true), + cacheEnabled: z.boolean().default(true), + cacheTTLMs: z.number().min(60000).max(86400000).default(3600000), + confidenceThreshold: z.number().min(0).max(1).default(0.7), + fallbackAgentType: z.string().default('coder'), + maxDescriptionLength: z.number().min(100).max(10000).default(1000), + // Claude 4.5 models: claude-haiku-4-5-20251001, claude-sonnet-4-5-20250929, claude-opus-4-5-20251101 + dispatchModel: z.string().default('claude-haiku-4-5-20251001'), +}); + const ConfigSchema = z.object({ version: z.string().default('1.0.0'), memory: MemoryConfigSchema.default({}), @@ -166,6 +177,7 @@ const ConfigSchema = z.object({ driftDetection: DriftDetectionConfigSchema.default({}), resourceExhaustion: ResourceExhaustionConfigSchema.default({}), consensus: ConsensusConfigSchema.default({}), + smartDispatcher: SmartDispatcherConfigSchema.default({}), }); const CONFIG_FILE_NAME = 'aistack.config.json'; diff --git a/src/web/routes/tasks.ts b/src/web/routes/tasks.ts index 5b00ce2..148da0d 100644 --- a/src/web/routes/tasks.ts +++ b/src/web/routes/tasks.ts @@ -9,6 +9,7 @@ import { badRequest, notFound } from '../middleware/error.js'; import { getMemoryManager } from '../../memory/index.js'; import { TaskQueue } from '../../coordination/task-queue.js'; import { agentEvents } from '../websocket/event-bridge.js'; +import { getSmartDispatcher } from '../../tasks/smart-dispatcher.js'; import type { CreateTaskRequest, AssignTaskRequest, CompleteTaskRequest } from '../types.js'; // Global task queue instance @@ -65,18 +66,39 @@ export function registerTaskRoutes(router: Router, config: AgentStackConfig): vo }); // POST /api/v1/tasks - Create task - router.post('/api/v1/tasks', (_req, res, params) => { + router.post('/api/v1/tasks', async (_req, res, params) => { const body = params.body as CreateTaskRequest | undefined; - if (!body?.agentType) { - throw badRequest('Agent type is required'); + let agentType = body?.agentType; + let dispatchInfo: { agentType: string; confidence: number; reasoning: string; cached: boolean } | undefined; + + // Auto-dispatch if no agent type specified + if (!agentType && body?.input) { + const dispatcher = getSmartDispatcher(config); + if (dispatcher.isEnabled()) { + const dispatchResult = await dispatcher.dispatch(body.input); + if (dispatchResult.success && dispatchResult.decision) { + agentType = dispatchResult.decision.agentType; + dispatchInfo = { + agentType: dispatchResult.decision.agentType, + confidence: dispatchResult.decision.confidence, + reasoning: dispatchResult.decision.reasoning, + cached: dispatchResult.decision.cached, + }; + } + } + } + + // Fallback to default agent type + if (!agentType) { + agentType = config.smartDispatcher?.fallbackAgentType ?? 'coder'; } const manager = getManager(); - const task = manager.createTask(body.agentType, body.input, body.sessionId); + const task = manager.createTask(agentType, body?.input, body?.sessionId); // Add to queue if priority is specified - if (body.priority !== undefined) { + if (body?.priority !== undefined) { const queue = getTaskQueue(); queue.enqueue(task, body.priority); } @@ -85,9 +107,31 @@ export function registerTaskRoutes(router: Router, config: AgentStackConfig): vo ...task, createdAt: task.createdAt.toISOString(), completedAt: task.completedAt?.toISOString(), + dispatch: dispatchInfo, }, 201); }); + // POST /api/v1/tasks/dispatch - Preview dispatch decision (doesn't create task) + router.post('/api/v1/tasks/dispatch', async (_req, res, params) => { + const body = params.body as { input?: string } | undefined; + + if (!body?.input) { + throw badRequest('Input is required for dispatch preview'); + } + + const dispatcher = getSmartDispatcher(config); + if (!dispatcher.isEnabled()) { + sendJson(res, { + success: false, + error: 'Smart dispatcher is not enabled', + }); + return; + } + + const result = await dispatcher.dispatch(body.input); + sendJson(res, result); + }); + // GET /api/v1/tasks/queue - Get queue status router.get('/api/v1/tasks/queue', (_req, res) => { const queue = getTaskQueue(); diff --git a/tests/integration/smart-dispatcher.test.ts b/tests/integration/smart-dispatcher.test.ts new file mode 100644 index 0000000..fb871f3 --- /dev/null +++ b/tests/integration/smart-dispatcher.test.ts @@ -0,0 +1,317 @@ +/** + * Smart Dispatcher Integration tests + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AgentStackConfig } from '../../src/types.js'; +import { MemoryManager } from '../../src/memory/index.js'; +import { createTaskTools } from '../../src/mcp/tools/task-tools.js'; +import { SmartDispatcher } from '../../src/tasks/smart-dispatcher.js'; + +// Mock fetch for LLM API calls +const mockFetch = vi.fn(); +const originalFetch = global.fetch; + +// Helper to create test config +function createConfig(tmpDir: string, options?: { + dispatcherEnabled?: boolean; + anthropicKey?: string; +}): AgentStackConfig { + return { + version: '1.0.0', + memory: { + path: join(tmpDir, 'memory.db'), + defaultNamespace: 'default', + vectorSearch: { enabled: false }, + }, + providers: { + default: 'anthropic', + anthropic: options?.anthropicKey ? { apiKey: options.anthropicKey } : undefined, + }, + agents: { maxConcurrent: 5, defaultTimeout: 300 }, + github: { enabled: false }, + plugins: { enabled: true, directory: './plugins' }, + mcp: { transport: 'stdio' }, + hooks: { sessionStart: true, sessionEnd: true, preTask: true, postTask: true }, + smartDispatcher: { + enabled: options?.dispatcherEnabled ?? true, + cacheEnabled: true, + cacheTTLMs: 3600000, + confidenceThreshold: 0.7, + fallbackAgentType: 'coder', + maxDescriptionLength: 1000, + dispatchModel: 'claude-haiku-4-5-20251001', + }, + }; +} + +// Helper to create mock LLM response +function createMockLLMResponse(agentType: string, confidence: number, reasoning: string) { + return { + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ agentType, confidence, reasoning }), + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }; +} + +describe('Smart Dispatcher Integration', () => { + let tmpDir: string; + let memory: MemoryManager; + let config: AgentStackConfig; + + beforeEach(() => { + mockFetch.mockReset(); + global.fetch = mockFetch; + + tmpDir = mkdtempSync(join(tmpdir(), 'smart-dispatch-test-')); + config = createConfig(tmpDir, { dispatcherEnabled: true, anthropicKey: 'sk-test' }); + memory = new MemoryManager(config); + }); + + afterEach(() => { + global.fetch = originalFetch; + memory.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('MCP task_create with auto-dispatch', () => { + it('should auto-dispatch when agentType is not provided', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('coder', 0.95, 'Task involves implementing a REST endpoint') + ); + + const result = await tools.task_create.handler({ + input: 'Create a REST endpoint for user authentication', + // agentType not provided + }); + + expect(result).toMatchObject({ + success: true, + task: expect.objectContaining({ + agentType: 'coder', + }), + dispatch: expect.objectContaining({ + agentType: 'coder', + confidence: 0.95, + reasoning: 'Task involves implementing a REST endpoint', + }), + }); + }); + + it('should use provided agentType when specified (backward compatibility)', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + const result = await tools.task_create.handler({ + agentType: 'tester', + input: 'Create a REST endpoint for user authentication', + }); + + expect(result).toMatchObject({ + success: true, + task: expect.objectContaining({ + agentType: 'tester', // Explicitly provided, not auto-dispatched + }), + }); + expect((result as { dispatch?: unknown }).dispatch).toBeUndefined(); + + // No LLM call should have been made + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use fallback when dispatcher is disabled', async () => { + const disabledConfig = createConfig(tmpDir, { dispatcherEnabled: false }); + const dispatcher = new SmartDispatcher(disabledConfig); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, disabledConfig); + + const result = await tools.task_create.handler({ + input: 'Create a REST endpoint', + // agentType not provided, dispatcher disabled + }); + + expect(result).toMatchObject({ + success: true, + task: expect.objectContaining({ + agentType: 'coder', // Fallback + }), + }); + expect((result as { dispatch?: unknown }).dispatch).toBeUndefined(); + }); + + it('should use fallback when no input is provided', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + const result = await tools.task_create.handler({ + // No input, no agentType + }); + + expect(result).toMatchObject({ + success: true, + task: expect.objectContaining({ + agentType: 'coder', // Fallback + }), + }); + }); + + it('should select different agent types based on task description', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + // Test various task types + const testCases = [ + { input: 'Write unit tests for UserService', expectedAgent: 'tester' }, + { input: 'Review the authentication module code', expectedAgent: 'reviewer' }, + { input: 'Set up Docker containers for the app', expectedAgent: 'devops' }, + { input: 'Document the API endpoints', expectedAgent: 'documentation' }, + { input: 'Find all usages of deprecated methods', expectedAgent: 'researcher' }, + ]; + + for (const testCase of testCases) { + mockFetch.mockResolvedValueOnce( + createMockLLMResponse(testCase.expectedAgent, 0.9, `${testCase.expectedAgent} task`) + ); + + const result = await tools.task_create.handler({ + input: testCase.input, + }); + + expect((result as { task: { agentType: string } }).task.agentType).toBe(testCase.expectedAgent); + } + }); + }); + + describe('MCP task_create with dispatcher and other services', () => { + it('should work alongside drift detection', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('coder', 0.9, 'Coding task') + ); + + const result = await tools.task_create.handler({ + input: 'Implement feature X', + }); + + expect(result).toMatchObject({ + success: true, + task: expect.objectContaining({ + agentType: 'coder', + }), + dispatch: expect.objectContaining({ + agentType: 'coder', + }), + }); + }); + + it('should handle LLM errors gracefully', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + mockFetch.mockRejectedValueOnce(new Error('API error')); + + const result = await tools.task_create.handler({ + input: 'Do something', + }); + + // Should still succeed with fallback agent + expect(result).toMatchObject({ + success: true, + task: expect.objectContaining({ + agentType: 'coder', // Fallback + }), + }); + }); + }); + + describe('Task creation without MCP tools', () => { + it('should create tasks with explicit agent type (backward compatibility)', () => { + const task = memory.createTask('architect', 'Design the system'); + + expect(task.agentType).toBe('architect'); + expect(task.input).toBe('Design the system'); + }); + + it('should list tasks created with auto-dispatch', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + mockFetch.mockResolvedValue( + createMockLLMResponse('coder', 0.9, 'Coding task') + ); + + await tools.task_create.handler({ input: 'Task 1' }); + await tools.task_create.handler({ input: 'Task 2' }); + await tools.task_create.handler({ agentType: 'tester', input: 'Task 3' }); + + const listResult = await tools.task_list.handler({}); + + expect((listResult as { count: number }).count).toBe(3); + const tasks = (listResult as { tasks: Array<{ agentType: string }> }).tasks; + const agentTypes = tasks.map(t => t.agentType).sort(); + // Should have 2 coder tasks (auto-dispatched) and 1 tester task (explicit) + expect(agentTypes).toEqual(['coder', 'coder', 'tester']); + }); + }); + + describe('Dispatcher caching across task creations', () => { + it('should cache dispatch results for identical descriptions', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('coder', 0.9, 'Coding task') + ); + + // First task + const result1 = await tools.task_create.handler({ + input: 'Create a login function', + }); + + // Second task with identical description + const result2 = await tools.task_create.handler({ + input: 'Create a login function', + }); + + expect((result1 as { dispatch?: { cached: boolean } }).dispatch?.cached).toBe(false); + expect((result2 as { dispatch?: { cached: boolean } }).dispatch?.cached).toBe(true); + + // Only one LLM call should have been made + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should make separate LLM calls for different descriptions', async () => { + const dispatcher = new SmartDispatcher(config); + const tools = createTaskTools(memory, undefined, undefined, dispatcher, config); + + mockFetch + .mockResolvedValueOnce(createMockLLMResponse('coder', 0.9, 'Coding task')) + .mockResolvedValueOnce(createMockLLMResponse('tester', 0.85, 'Testing task')); + + await tools.task_create.handler({ + input: 'Create a login function', + }); + + await tools.task_create.handler({ + input: 'Write tests for login', + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/unit/mcp-task-tools.test.ts b/tests/unit/mcp-task-tools.test.ts index 65e2576..da97293 100644 --- a/tests/unit/mcp-task-tools.test.ts +++ b/tests/unit/mcp-task-tools.test.ts @@ -96,10 +96,12 @@ describe('MCP Task Tools', () => { expect(() => new Date(result.task.createdAt)).not.toThrow(); }); - it('should throw for missing agentType', async () => { - await expect( - tools.task_create.handler({}) - ).rejects.toThrow(); + it('should use fallback agentType when missing (auto-dispatch)', async () => { + // When agentType is not provided and no dispatcher, should use fallback + const result = await tools.task_create.handler({}); + + expect(result.success).toBe(true); + expect(result.task.agentType).toBe('coder'); // Default fallback }); it('should throw for empty agentType', async () => { @@ -112,7 +114,8 @@ describe('MCP Task Tools', () => { it('should have correct tool definition', () => { expect(tools.task_create.name).toBe('task_create'); - expect(tools.task_create.inputSchema.required).toContain('agentType'); + // agentType is now optional (auto-dispatch feature) + expect(tools.task_create.inputSchema.required).toEqual([]); }); }); diff --git a/tests/unit/smart-dispatcher.test.ts b/tests/unit/smart-dispatcher.test.ts new file mode 100644 index 0000000..e4cc489 --- /dev/null +++ b/tests/unit/smart-dispatcher.test.ts @@ -0,0 +1,888 @@ +/** + * Smart Dispatcher Service tests + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + SmartDispatcher, + getSmartDispatcher, + resetSmartDispatcher, +} from '../../src/tasks/smart-dispatcher.js'; +import type { AgentStackConfig } from '../../src/types.js'; +import * as providers from '../../src/providers/index.js'; + +// Mock fetch for LLM API calls +const mockFetch = vi.fn(); +const originalFetch = global.fetch; + +// Helper to create test config +function createConfig(options: { + enabled?: boolean; + cacheEnabled?: boolean; + cacheTTLMs?: number; + confidenceThreshold?: number; + fallbackAgentType?: string; + maxDescriptionLength?: number; + dispatchModel?: string; + anthropicKey?: string; +}): AgentStackConfig { + return { + version: '1.0.0', + memory: { + path: './data/memory.db', + defaultNamespace: 'default', + vectorSearch: { enabled: false }, + }, + providers: { + default: 'anthropic', + anthropic: options.anthropicKey ? { apiKey: options.anthropicKey } : undefined, + }, + agents: { maxConcurrent: 5, defaultTimeout: 300 }, + github: { enabled: false }, + plugins: { enabled: true, directory: './plugins' }, + mcp: { transport: 'stdio' }, + hooks: { sessionStart: true, sessionEnd: true, preTask: true, postTask: true }, + smartDispatcher: { + enabled: options.enabled ?? true, + cacheEnabled: options.cacheEnabled ?? true, + cacheTTLMs: options.cacheTTLMs ?? 3600000, + confidenceThreshold: options.confidenceThreshold ?? 0.7, + fallbackAgentType: options.fallbackAgentType ?? 'coder', + maxDescriptionLength: options.maxDescriptionLength ?? 1000, + dispatchModel: options.dispatchModel ?? 'claude-haiku-4-5-20251001', + }, + }; +} + +// Helper to create mock LLM response +function createMockLLMResponse(agentType: string, confidence: number, reasoning: string) { + return { + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ agentType, confidence, reasoning }), + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }; +} + +describe('SmartDispatcher', () => { + beforeEach(() => { + mockFetch.mockReset(); + global.fetch = mockFetch; + resetSmartDispatcher(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('isEnabled', () => { + it('should return false when disabled in config', () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: false, anthropicKey: 'sk-test' }) + ); + + expect(dispatcher.isEnabled()).toBe(false); + }); + + it('should return false when no provider is available', () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: undefined }) + ); + + expect(dispatcher.isEnabled()).toBe(false); + }); + + it('should return true when enabled with valid provider', () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + expect(dispatcher.isEnabled()).toBe(true); + }); + + it('should return false when provider creation fails', () => { + // Mock getProvider to throw an error + const getProviderSpy = vi.spyOn(providers, 'getProvider').mockImplementation(() => { + throw new Error('Provider initialization failed'); + }); + + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: undefined }) + ); + + expect(dispatcher.isEnabled()).toBe(false); + + getProviderSpy.mockRestore(); + }); + }); + + describe('dispatch', () => { + it('should return error when disabled', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: false }) + ); + + const result = await dispatcher.dispatch('Create a REST endpoint'); + + expect(result.success).toBe(false); + expect(result.error).toContain('not enabled'); + }); + + it('should select coder for coding tasks', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('coder', 0.95, 'Task involves implementing code') + ); + + const result = await dispatcher.dispatch('Create a REST endpoint for user authentication'); + + expect(result.success).toBe(true); + expect(result.decision?.agentType).toBe('coder'); + expect(result.decision?.confidence).toBe(0.95); + expect(result.decision?.cached).toBe(false); + }); + + it('should select researcher for research tasks', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('researcher', 0.9, 'Task involves code exploration') + ); + + const result = await dispatcher.dispatch('Find all usages of the UserService class'); + + expect(result.success).toBe(true); + expect(result.decision?.agentType).toBe('researcher'); + }); + + it('should select tester for testing tasks', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('tester', 0.88, 'Task involves writing tests') + ); + + const result = await dispatcher.dispatch('Write unit tests for the AuthController'); + + expect(result.success).toBe(true); + expect(result.decision?.agentType).toBe('tester'); + }); + + it('should use fallback when confidence is below threshold', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + confidenceThreshold: 0.8, + fallbackAgentType: 'coordinator', + }) + ); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('analyst', 0.5, 'Uncertain about task type') + ); + + const result = await dispatcher.dispatch('Do something with the data'); + + expect(result.success).toBe(true); + expect(result.decision?.agentType).toBe('coordinator'); // Fallback + expect(result.decision?.reasoning).toContain('Low confidence'); + }); + + it('should use fallback on LLM API error', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + fallbackAgentType: 'coder', + }) + ); + + mockFetch.mockRejectedValueOnce(new Error('API rate limit exceeded')); + + const result = await dispatcher.dispatch('Build a feature'); + + expect(result.success).toBe(true); + expect(result.decision?.agentType).toBe('coder'); // Fallback + expect(result.decision?.confidence).toBe(0); + expect(result.decision?.reasoning).toContain('failed'); + }); + + it('should truncate long descriptions', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + maxDescriptionLength: 50, + }) + ); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('coder', 0.9, 'Code task') + ); + + const longDescription = 'A'.repeat(200); + await dispatcher.dispatch(longDescription); + + // Verify the API was called with truncated text + expect(mockFetch).toHaveBeenCalled(); + const callArgs = mockFetch.mock.calls[0][1]; + const body = JSON.parse(callArgs.body); + const userMessage = body.messages.find((m: { role: string }) => m.role === 'user'); + expect(userMessage.content.length).toBe(50); + }); + }); + + describe('cache', () => { + it('should cache dispatch results', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + cacheEnabled: true, + }) + ); + + mockFetch.mockResolvedValueOnce( + createMockLLMResponse('coder', 0.9, 'Code task') + ); + + // First call - should hit LLM + const result1 = await dispatcher.dispatch('Create a function'); + expect(result1.decision?.cached).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second call with same description - should be cached + const result2 = await dispatcher.dispatch('Create a function'); + expect(result2.decision?.cached).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); // No additional API call + }); + + it('should not cache when caching is disabled', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + cacheEnabled: false, + }) + ); + + mockFetch + .mockResolvedValueOnce(createMockLLMResponse('coder', 0.9, 'Code task')) + .mockResolvedValueOnce(createMockLLMResponse('coder', 0.9, 'Code task')); + + await dispatcher.dispatch('Create a function'); + await dispatcher.dispatch('Create a function'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should expire cached entries after TTL', async () => { + vi.useFakeTimers(); + + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + cacheEnabled: true, + cacheTTLMs: 1000, // 1 second + }) + ); + + mockFetch + .mockResolvedValueOnce(createMockLLMResponse('coder', 0.9, 'Code task')) + .mockResolvedValueOnce(createMockLLMResponse('coder', 0.9, 'Code task')); + + await dispatcher.dispatch('Create a function'); + + // Advance time past TTL + vi.advanceTimersByTime(2000); + + const result = await dispatcher.dispatch('Create a function'); + expect(result.decision?.cached).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); + + it('should clear cache when clearCache is called', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + cacheEnabled: true, + }) + ); + + mockFetch + .mockResolvedValueOnce(createMockLLMResponse('coder', 0.9, 'Code task')) + .mockResolvedValueOnce(createMockLLMResponse('coder', 0.9, 'Code task')); + + await dispatcher.dispatch('Create a function'); + dispatcher.clearCache(); + await dispatcher.dispatch('Create a function'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should trigger cache cleanup when cache exceeds 1000 entries', async () => { + vi.useFakeTimers(); + + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + cacheEnabled: true, + cacheTTLMs: 1000, // Short TTL for cleanup test + }) + ); + + // Mock fetch to always return valid response + mockFetch.mockResolvedValue(createMockLLMResponse('coder', 0.9, 'Code task')); + + // Fill cache with 1001 unique entries + for (let i = 0; i < 1001; i++) { + await dispatcher.dispatch(`Unique task ${i}`); + } + + expect(dispatcher.getCacheStats().size).toBe(1001); + + // Advance time past TTL to make all entries expired + vi.advanceTimersByTime(2000); + + // Next dispatch should trigger cleanup of expired entries + await dispatcher.dispatch('Trigger cleanup task'); + + // Cache should have only the new entry after cleanup + expect(dispatcher.getCacheStats().size).toBe(1); + + vi.useRealTimers(); + }); + + it('should clean expired entries during cache maintenance', async () => { + vi.useFakeTimers(); + + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + cacheEnabled: true, + cacheTTLMs: 500, // Very short TTL + }) + ); + + mockFetch.mockResolvedValue(createMockLLMResponse('coder', 0.9, 'Code task')); + + // Add some entries + await dispatcher.dispatch('Task A'); + await dispatcher.dispatch('Task B'); + expect(dispatcher.getCacheStats().size).toBe(2); + + // Advance time to expire entries + vi.advanceTimersByTime(1000); + + // Fill cache past threshold to trigger cleanup + for (let i = 0; i < 1000; i++) { + await dispatcher.dispatch(`Filler task ${i}`); + } + + // The expired entries (Task A, Task B) should have been cleaned + // Cache should contain only the 1000 filler tasks + cleanup trigger + const stats = dispatcher.getCacheStats(); + expect(stats.size).toBeLessThanOrEqual(1001); + expect(stats.size).toBeGreaterThan(0); + + vi.useRealTimers(); + }); + }); + + describe('parseResponse', () => { + it('should parse valid JSON response', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"agentType":"devops","confidence":0.85,"reasoning":"Infrastructure task"}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Set up CI/CD pipeline'); + + expect(result.decision?.agentType).toBe('devops'); + expect(result.decision?.confidence).toBe(0.85); + expect(result.decision?.reasoning).toBe('Infrastructure task'); + }); + + it('should handle JSON in markdown code block', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '```json\n{"agentType":"reviewer","confidence":0.8,"reasoning":"Code review"}\n```', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Review this PR'); + + expect(result.decision?.agentType).toBe('reviewer'); + }); + + it('should normalize agent type with underscores', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"agentType":"security_auditor","confidence":0.9,"reasoning":"Security task"}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Audit security vulnerabilities'); + + expect(result.decision?.agentType).toBe('security-auditor'); + }); + + it('should use fallback for invalid agent type', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + fallbackAgentType: 'coder', + }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"agentType":"invalid_type","confidence":0.9,"reasoning":"Unknown type"}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Do something'); + + expect(result.decision?.agentType).toBe('coder'); // Fallback + }); + + it('should handle missing confidence field', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"agentType":"coder","reasoning":"Code task"}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Write code'); + + expect(result.decision?.agentType).toBe('coder'); + expect(result.decision?.confidence).toBe(0.5); // Default + }); + + it('should clamp confidence to 0-1 range', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ enabled: true, anthropicKey: 'sk-test' }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"agentType":"coder","confidence":1.5,"reasoning":"Very confident"}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Write code'); + + expect(result.decision?.confidence).toBe(1); + }); + + it('should handle malformed JSON', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + fallbackAgentType: 'coordinator', + confidenceThreshold: 0, // No threshold so we test parse failure directly + }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: 'This is not valid JSON at all', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Do something'); + + expect(result.decision?.agentType).toBe('coordinator'); // Fallback + expect(result.decision?.confidence).toBe(0); + // Note: reasoning will contain "Failed to parse" from the parse error + expect(result.decision?.reasoning).toContain('Failed to parse'); + }); + }); + + describe('getSmartDispatcher (singleton)', () => { + it('should return the same instance for same config', () => { + const config = createConfig({ enabled: true, anthropicKey: 'sk-test' }); + + const dispatcher1 = getSmartDispatcher(config); + const dispatcher2 = getSmartDispatcher(config); + + expect(dispatcher1).toBe(dispatcher2); + }); + + it('should create new instance when config changes', () => { + const config1 = createConfig({ enabled: true, confidenceThreshold: 0.7, anthropicKey: 'sk-test' }); + const config2 = createConfig({ enabled: true, confidenceThreshold: 0.8, anthropicKey: 'sk-test' }); + + const dispatcher1 = getSmartDispatcher(config1); + const dispatcher2 = getSmartDispatcher(config2); + + expect(dispatcher1).not.toBe(dispatcher2); + expect(dispatcher1.getConfig().confidenceThreshold).toBe(0.7); + expect(dispatcher2.getConfig().confidenceThreshold).toBe(0.8); + }); + + it('should create new instance when forceNew is true', () => { + const config = createConfig({ enabled: true, anthropicKey: 'sk-test' }); + + const dispatcher1 = getSmartDispatcher(config); + const dispatcher2 = getSmartDispatcher(config, true); + + expect(dispatcher1).not.toBe(dispatcher2); + }); + + it('should handle config with undefined smartDispatcher', () => { + const config: AgentStackConfig = { + version: '1.0.0', + memory: { + path: './data/memory.db', + defaultNamespace: 'default', + vectorSearch: { enabled: false }, + }, + providers: { default: 'anthropic' }, + agents: { maxConcurrent: 5, defaultTimeout: 300 }, + github: { enabled: false }, + plugins: { enabled: true, directory: './plugins' }, + mcp: { transport: 'stdio' }, + hooks: { sessionStart: true, sessionEnd: true, preTask: true, postTask: true }, + // smartDispatcher is undefined + }; + + const dispatcher = getSmartDispatcher(config); + expect(dispatcher).toBeDefined(); + // Should use default config + expect(dispatcher.getConfig().enabled).toBe(true); + }); + + it('should compare configs correctly when both are undefined', () => { + // First call with undefined smartDispatcher + const config1: AgentStackConfig = { + version: '1.0.0', + memory: { path: './test.db', defaultNamespace: 'default', vectorSearch: { enabled: false } }, + providers: { default: 'anthropic' }, + agents: { maxConcurrent: 5, defaultTimeout: 300 }, + github: { enabled: false }, + plugins: { enabled: true, directory: './plugins' }, + mcp: { transport: 'stdio' }, + hooks: { sessionStart: true, sessionEnd: true, preTask: true, postTask: true }, + }; + + const dispatcher1 = getSmartDispatcher(config1); + + // Second call with same undefined smartDispatcher should return same instance + const config2: AgentStackConfig = { ...config1 }; + const dispatcher2 = getSmartDispatcher(config2); + + // Both should have default config and be same instance + expect(dispatcher1.getConfig().enabled).toBe(true); + expect(dispatcher2).toBe(dispatcher1); + }); + + it('should create new instance when only one config has smartDispatcher', () => { + // First with undefined + const config1: AgentStackConfig = { + version: '1.0.0', + memory: { path: './test.db', defaultNamespace: 'default', vectorSearch: { enabled: false } }, + providers: { default: 'anthropic' }, + agents: { maxConcurrent: 5, defaultTimeout: 300 }, + github: { enabled: false }, + plugins: { enabled: true, directory: './plugins' }, + mcp: { transport: 'stdio' }, + hooks: { sessionStart: true, sessionEnd: true, preTask: true, postTask: true }, + }; + + const dispatcher1 = getSmartDispatcher(config1); + + // Second with defined smartDispatcher + const config2 = createConfig({ enabled: true, anthropicKey: 'sk-test' }); + const dispatcher2 = getSmartDispatcher(config2); + + // Should be different instances + expect(dispatcher2).not.toBe(dispatcher1); + }); + }); + + describe('getCacheStats', () => { + it('should return cache statistics', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + cacheEnabled: true, + }) + ); + + mockFetch.mockResolvedValue( + createMockLLMResponse('coder', 0.9, 'Code task') + ); + + expect(dispatcher.getCacheStats().size).toBe(0); + expect(dispatcher.getCacheStats().enabled).toBe(true); + + await dispatcher.dispatch('Task 1'); + expect(dispatcher.getCacheStats().size).toBe(1); + + await dispatcher.dispatch('Task 2'); + expect(dispatcher.getCacheStats().size).toBe(2); + + dispatcher.clearCache(); + expect(dispatcher.getCacheStats().size).toBe(0); + }); + }); + + describe('getConfig', () => { + it('should return current configuration', () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + cacheEnabled: false, + cacheTTLMs: 7200000, + confidenceThreshold: 0.85, + fallbackAgentType: 'architect', + maxDescriptionLength: 500, + dispatchModel: 'claude-3-opus-20240229', + anthropicKey: 'sk-test', + }) + ); + + const config = dispatcher.getConfig(); + + expect(config.enabled).toBe(true); + expect(config.cacheEnabled).toBe(false); + expect(config.cacheTTLMs).toBe(7200000); + expect(config.confidenceThreshold).toBe(0.85); + expect(config.fallbackAgentType).toBe('architect'); + expect(config.maxDescriptionLength).toBe(500); + expect(config.dispatchModel).toBe('claude-3-opus-20240229'); + }); + }); + + describe('selectAgentType', () => { + it('should throw when no provider is available', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: undefined, // No API key = no provider + }) + ); + + await expect(dispatcher.selectAgentType('Some task')).rejects.toThrow( + 'No provider available' + ); + }); + }); + + describe('parseResponse edge cases', () => { + it('should handle missing agentType in JSON', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + fallbackAgentType: 'coordinator', + confidenceThreshold: 0, + }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"confidence":0.9,"reasoning":"No agent type"}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Do something'); + + expect(result.decision?.agentType).toBe('coordinator'); // Fallback + expect(result.decision?.confidence).toBe(0); + expect(result.decision?.reasoning).toContain('Invalid or missing agentType'); + }); + + it('should handle non-string agentType in JSON', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + fallbackAgentType: 'coder', + confidenceThreshold: 0, + }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"agentType":123,"confidence":0.9,"reasoning":"Invalid type"}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Do something'); + + expect(result.decision?.agentType).toBe('coder'); // Fallback + expect(result.decision?.reasoning).toContain('Invalid or missing agentType'); + }); + + it('should provide default reasoning when not in response', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"agentType":"coder","confidence":0.9}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Write code'); + + expect(result.decision?.agentType).toBe('coder'); + expect(result.decision?.reasoning).toBe('No reasoning provided'); + }); + + it('should handle non-string reasoning in JSON', async () => { + const dispatcher = new SmartDispatcher( + createConfig({ + enabled: true, + anthropicKey: 'sk-test', + }) + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + content: [ + { + type: 'text', + text: '{"agentType":"coder","confidence":0.9,"reasoning":123}', + }, + ], + model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + }); + + const result = await dispatcher.dispatch('Write code'); + + expect(result.decision?.agentType).toBe('coder'); + expect(result.decision?.reasoning).toBe('No reasoning provided'); + }); + }); +}); diff --git a/tests/unit/web/routes.test.ts b/tests/unit/web/routes.test.ts index 783c760..a6a58fe 100644 --- a/tests/unit/web/routes.test.ts +++ b/tests/unit/web/routes.test.ts @@ -700,14 +700,16 @@ describe('Task Routes', () => { expect(res.writeHead).toHaveBeenCalledWith(201, expect.any(Object)); }); - it('should return error for missing agentType', async () => { + it('should use fallback agentType when missing (auto-dispatch)', async () => { const req = createMockRequest('POST', '/api/v1/tasks', {}); const res = createMockResponse(); await router.handle(req, res); const body = res.getBody(); - expect(body.success).toBe(false); + // With SmartDispatcher feature, missing agentType uses fallback + expect(body.success).toBe(true); + expect(body.data.agentType).toBe('coder'); // Default fallback }); });