aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
847 lines (719 loc) • 25.7 kB
JavaScript
/**
* Subagent Dispatcher
* Story 10.2 - Parallel Agent Execution
* Story GEMINI-INT.3 - Multi-Provider Support
*
* Dispatches tasks to specialized subagents based on task type.
* Supports multiple AI providers (Claude, Gemini) with routing and fallback.
* Injects relevant context from Memory Layer.
*/
const EventEmitter = require('events');
const { spawn } = require('child_process');
const _path = require('path');
// Import AI Provider Factory
let AIProviderFactory;
try {
AIProviderFactory = require('../../infrastructure/integrations/ai-providers');
} catch {
AIProviderFactory = null;
}
// Import dependencies with fallbacks
let MemoryQuery, GotchasMemory;
try {
MemoryQuery = require('../memory/memory-query');
} catch {
MemoryQuery = null;
}
try {
GotchasMemory = require('../memory/gotchas-memory');
} catch {
GotchasMemory = null;
}
class SubagentDispatcher extends EventEmitter {
constructor(config = {}) {
super();
// Agent mapping: task type → agent
this.agentMapping = config.agentMapping || {
database: '@data-engineer',
db: '@data-engineer',
migration: '@data-engineer',
api: '@dev',
backend: '@dev',
frontend: '@dev',
component: '@dev',
feature: '@dev',
bugfix: '@dev',
test: '@qa',
testing: '@qa',
review: '@qa',
deploy: '@devops',
infrastructure: '@devops',
ci: '@devops',
architecture: '@architect',
design: '@architect',
documentation: '@pm',
docs: '@pm',
planning: '@pm',
analysis: '@analyst',
research: '@analyst',
};
// Default agent when no match
this.defaultAgent = config.defaultAgent || '@dev';
// AI Provider configuration (Story GEMINI-INT.3)
this.providerMapping = config.providerMapping || {
// Tasks that benefit from Claude's deep reasoning
'@architect': 'claude',
'@analyst': 'claude',
security: 'claude',
// Tasks that work well with Gemini's speed
'@dev': 'auto', // Use configured default
'@qa': 'gemini',
'@pm': 'gemini',
documentation: 'gemini',
formatting: 'gemini',
};
// Default provider (from config or claude)
this.defaultProvider = config.defaultProvider || 'claude';
// Enable multi-provider features
this.multiProviderEnabled = config.multiProviderEnabled !== false;
// Parallel execution mode
this.parallelMode = config.parallelMode || 'fallback'; // fallback, race, consensus, best-of
// Retry configuration
this.maxRetries = config.maxRetries || 2;
this.retryDelay = config.retryDelay || 2000;
// Dependencies
this.memoryQuery = config.memoryQuery || (MemoryQuery ? new MemoryQuery() : null);
this.gotchasMemory = config.gotchasMemory || (GotchasMemory ? new GotchasMemory() : null);
// Dispatch log
this.dispatchLog = [];
this.maxLogSize = 100;
// Root path for project
this.rootPath = config.rootPath || process.cwd();
}
/**
* Dispatch a task to the appropriate subagent
* @param {Object} task - Task to dispatch
* @param {Object} context - Execution context
* @returns {Promise<Object>} - Dispatch result
*/
async dispatch(task, context = {}) {
const agentId = this.resolveAgent(task);
const startTime = Date.now();
// Resolve provider (Story GEMINI-INT.3)
const providerName = this.resolveProvider(task, agentId);
// Create dispatch record
const dispatchRecord = {
id: `dispatch-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
taskId: task.id,
agentId,
provider: providerName,
startedAt: new Date().toISOString(),
attempts: 0,
};
this.emit('dispatch_started', dispatchRecord);
this.log('dispatch_started', dispatchRecord);
// Enrich context
const enrichedContext = await this.enrichContext(task, context);
dispatchRecord.contextSize = JSON.stringify(enrichedContext).length;
// Execute with retries
let lastError = null;
for (let attempt = 1; attempt <= this.maxRetries + 1; attempt++) {
dispatchRecord.attempts = attempt;
try {
const result = await this.spawnSubagent(agentId, task, enrichedContext);
dispatchRecord.completedAt = new Date().toISOString();
dispatchRecord.success = result.success;
dispatchRecord.duration = Date.now() - startTime;
dispatchRecord.outputSize = result.output?.length || 0;
this.emit('dispatch_completed', dispatchRecord);
this.log('dispatch_completed', dispatchRecord);
return {
success: result.success,
output: result.output,
agentId,
taskId: task.id,
duration: dispatchRecord.duration,
filesModified: result.filesModified || [],
};
} catch (error) {
lastError = error;
this.log('dispatch_attempt_failed', {
...dispatchRecord,
attempt,
error: error.message,
});
if (attempt <= this.maxRetries) {
this.emit('dispatch_retry', { taskId: task.id, attempt, error: error.message });
await this.sleep(this.retryDelay);
}
}
}
// All retries failed
dispatchRecord.completedAt = new Date().toISOString();
dispatchRecord.success = false;
dispatchRecord.error = lastError?.message || 'Unknown error';
dispatchRecord.duration = Date.now() - startTime;
this.emit('dispatch_failed', dispatchRecord);
this.log('dispatch_failed', dispatchRecord);
return {
success: false,
error: lastError?.message || 'Unknown error',
agentId,
taskId: task.id,
duration: dispatchRecord.duration,
filesModified: [],
};
}
/**
* Resolve which agent should handle a task
* @param {Object} task - Task to resolve agent for
* @returns {string} - Agent identifier
*/
resolveAgent(task) {
// Check explicit agent assignment
if (task.agent) {
return task.agent.startsWith('@') ? task.agent : `@${task.agent}`;
}
// Check task type
if (task.type && this.agentMapping[task.type.toLowerCase()]) {
return this.agentMapping[task.type.toLowerCase()];
}
// Check task tags
if (task.tags && Array.isArray(task.tags)) {
for (const tag of task.tags) {
if (this.agentMapping[tag.toLowerCase()]) {
return this.agentMapping[tag.toLowerCase()];
}
}
}
// Infer from task description
const description = (task.description || '').toLowerCase();
const inferencePatterns = [
{ patterns: ['database', 'sql', 'migration', 'schema'], agent: '@data-engineer' },
{ patterns: ['test', 'spec', 'coverage', 'assert'], agent: '@qa' },
{ patterns: ['deploy', 'docker', 'ci/cd', 'pipeline', 'kubernetes'], agent: '@devops' },
{ patterns: ['architect', 'design pattern', 'structure'], agent: '@architect' },
{ patterns: ['document', 'readme', 'guide'], agent: '@pm' },
{ patterns: ['analyze', 'research', 'investigate'], agent: '@analyst' },
];
for (const { patterns, agent } of inferencePatterns) {
if (patterns.some((p) => description.includes(p))) {
return agent;
}
}
// Default
return this.defaultAgent;
}
/**
* Resolve which AI provider should handle a task (Story GEMINI-INT.3)
* @param {Object} task - Task to resolve provider for
* @param {string} agentId - Resolved agent ID
* @returns {string} - Provider name ('claude', 'gemini', or 'auto')
*/
resolveProvider(task, agentId) {
// Check for explicit @gemini or @claude tag in task
if (task.provider) {
return task.provider.toLowerCase();
}
// Check task tags for provider hints
if (task.tags && Array.isArray(task.tags)) {
if (task.tags.includes('@gemini') || task.tags.includes('gemini')) {
return 'gemini';
}
if (task.tags.includes('@claude') || task.tags.includes('claude')) {
return 'claude';
}
}
// Check description for provider hints
const description = (task.description || '').toLowerCase();
if (description.includes('@gemini')) return 'gemini';
if (description.includes('@claude')) return 'claude';
// Check agent mapping
if (this.providerMapping[agentId]) {
const mapped = this.providerMapping[agentId];
if (mapped !== 'auto') return mapped;
}
// Check task type mapping
if (task.type && this.providerMapping[task.type.toLowerCase()]) {
const mapped = this.providerMapping[task.type.toLowerCase()];
if (mapped !== 'auto') return mapped;
}
// Return default
return this.defaultProvider;
}
/**
* Get AI provider instance (Story GEMINI-INT.3)
* @param {string} providerName - Provider name
* @returns {Object|null} - Provider instance or null
*/
getAIProvider(providerName) {
if (!AIProviderFactory) {
return null;
}
try {
return AIProviderFactory.getProvider(providerName);
} catch (error) {
this.log('provider_error', { provider: providerName, error: error.message });
return null;
}
}
/**
* Enrich context with memory and gotchas
* @param {Object} task - Task being dispatched
* @param {Object} context - Base context
* @returns {Promise<Object>} - Enriched context
*/
async enrichContext(task, context) {
const enriched = { ...context };
// Get relevant memory
if (this.memoryQuery) {
try {
const memory = await this.memoryQuery.getContextForAgent(
this.resolveAgent(task),
task.description,
);
enriched.memory = memory.relevantMemory || [];
enriched.patterns = memory.suggestedPatterns || [];
} catch (error) {
this.log('memory_query_failed', { taskId: task.id, error: error.message });
}
}
// Get relevant gotchas
if (this.gotchasMemory) {
try {
const gotchas = await this.gotchasMemory.getContextForTask(task.description);
enriched.gotchas = gotchas.filter((g) => this.isRelevantGotcha(g, task));
} catch (error) {
this.log('gotchas_query_failed', { taskId: task.id, error: error.message });
}
}
// Add project context
enriched.projectContext = await this.getProjectContext();
return enriched;
}
/**
* Check if a gotcha is relevant to a task
* @param {Object} gotcha - Gotcha to check
* @param {Object} task - Task being executed
* @returns {boolean} - True if relevant
*/
isRelevantGotcha(gotcha, task) {
const taskText =
`${task.description} ${task.type || ''} ${(task.tags || []).join(' ')}`.toLowerCase();
// Check if gotcha pattern appears in task
if (gotcha.pattern && taskText.includes(gotcha.pattern.toLowerCase())) {
return true;
}
// Check if gotcha category matches task type
if (gotcha.category && task.type && gotcha.category.toLowerCase() === task.type.toLowerCase()) {
return true;
}
// Check for keyword overlap
const gotchaKeywords = (gotcha.description || '').toLowerCase().split(/\s+/);
const taskKeywords = taskText.split(/\s+/);
const overlap = gotchaKeywords.filter((k) => taskKeywords.includes(k) && k.length > 3);
return overlap.length >= 2;
}
/**
* Get project context
* @returns {Promise<Object>} - Project context
*/
async getProjectContext() {
// This would read from .aios/codebase-map.json or similar
return {
rootPath: this.rootPath,
framework: 'aios-core',
timestamp: new Date().toISOString(),
};
}
/**
* Spawn a subagent to execute a task (Updated for Story GEMINI-INT.3)
* @param {string} agentId - Agent identifier
* @param {Object} task - Task to execute
* @param {Object} context - Enriched context
* @returns {Promise<Object>} - Execution result
*/
async spawnSubagent(agentId, task, context) {
// Build the prompt
const prompt = this.buildPrompt(agentId, task, context);
// Resolve provider
const providerName = this.resolveProvider(task, agentId);
// Try to use AI Provider Factory if available
if (this.multiProviderEnabled && AIProviderFactory) {
return this.executeWithProvider(prompt, providerName, task);
}
// Fallback to direct Claude CLI
return this.executeClaude(prompt);
}
/**
* Execute prompt using AI Provider Factory (Story GEMINI-INT.3)
* @param {string} prompt - Prompt to execute
* @param {string} providerName - Provider to use
* @param {Object} task - Original task for context
* @returns {Promise<Object>} - Execution result
*/
async executeWithProvider(prompt, providerName, task) {
const startTime = Date.now();
// Get primary provider
const provider = this.getAIProvider(providerName);
if (!provider) {
this.log('provider_unavailable', { provider: providerName });
// Fallback to legacy Claude execution
return this.executeClaude(prompt);
}
// Check availability
const isAvailable = await provider.checkAvailability();
if (!isAvailable) {
this.log('provider_not_available', { provider: providerName });
// Try fallback provider
const fallbackName = providerName === 'claude' ? 'gemini' : 'claude';
const fallback = this.getAIProvider(fallbackName);
if (fallback && (await fallback.checkAvailability())) {
this.log('using_fallback_provider', { original: providerName, fallback: fallbackName });
return this.executeWithSingleProvider(fallback, prompt, task);
}
// Last resort: legacy Claude
return this.executeClaude(prompt);
}
// Execute with selected provider
return this.executeWithSingleProvider(provider, prompt, task);
}
/**
* Execute with a single provider instance (Story GEMINI-INT.3)
* @param {Object} provider - Provider instance
* @param {string} prompt - Prompt to execute
* @param {Object} task - Original task
* @returns {Promise<Object>} - Execution result
*/
async executeWithSingleProvider(provider, prompt, task) {
try {
const response = await provider.executeWithRetry(prompt, {
workingDir: this.rootPath,
});
this.emit('provider_execution_complete', {
provider: provider.name,
taskId: task.id,
success: response.success,
duration: response.metadata?.duration,
});
return {
success: response.success,
output: response.output,
filesModified: this.extractModifiedFiles(response.output),
provider: provider.name,
metadata: response.metadata,
};
} catch (error) {
this.emit('provider_execution_failed', {
provider: provider.name,
taskId: task.id,
error: error.message,
});
throw error;
}
}
/**
* Execute with parallel providers (Story GEMINI-INT.3 - Advanced)
* Runs both Claude and Gemini, returns based on parallelMode
* @param {string} prompt - Prompt to execute
* @param {Object} task - Original task
* @returns {Promise<Object>} - Best/merged result
*/
async executeParallel(prompt, task) {
if (!AIProviderFactory) {
return this.executeClaude(prompt);
}
const claude = this.getAIProvider('claude');
const gemini = this.getAIProvider('gemini');
// Check availability
const [claudeAvail, geminiAvail] = await Promise.all([
claude?.checkAvailability() || false,
gemini?.checkAvailability() || false,
]);
if (!claudeAvail && !geminiAvail) {
throw new Error('No AI providers available');
}
if (!claudeAvail) return this.executeWithSingleProvider(gemini, prompt, task);
if (!geminiAvail) return this.executeWithSingleProvider(claude, prompt, task);
// Execute in parallel
const startTime = Date.now();
this.emit('parallel_execution_started', {
taskId: task.id,
mode: this.parallelMode,
});
const results = await Promise.allSettled([
this.executeWithSingleProvider(claude, prompt, task),
this.executeWithSingleProvider(gemini, prompt, task),
]);
const claudeResult = results[0].status === 'fulfilled' ? results[0].value : null;
const geminiResult = results[1].status === 'fulfilled' ? results[1].value : null;
this.emit('parallel_execution_complete', {
taskId: task.id,
duration: Date.now() - startTime,
claudeSuccess: !!claudeResult?.success,
geminiSuccess: !!geminiResult?.success,
});
// Select result based on mode
return this.selectParallelResult(claudeResult, geminiResult, task);
}
/**
* Select result from parallel execution based on mode
* @param {Object|null} claudeResult - Claude result
* @param {Object|null} geminiResult - Gemini result
* @param {Object} task - Original task
* @returns {Object} - Selected result
*/
selectParallelResult(claudeResult, geminiResult, task) {
switch (this.parallelMode) {
case 'race':
// Return first successful result
return claudeResult?.success ? claudeResult : geminiResult || claudeResult;
case 'consensus':
// Both must succeed and be similar
if (claudeResult?.success && geminiResult?.success) {
// Simple check: both succeeded
return {
...claudeResult,
consensus: true,
providers: ['claude', 'gemini'],
};
}
// Return whichever succeeded
return claudeResult?.success ? claudeResult : geminiResult;
case 'best-of':
// Return longer/more complete response (simple heuristic)
if (claudeResult?.success && geminiResult?.success) {
const claudeLen = claudeResult.output?.length || 0;
const geminiLen = geminiResult.output?.length || 0;
return claudeLen >= geminiLen ? claudeResult : geminiResult;
}
return claudeResult?.success ? claudeResult : geminiResult;
case 'fallback':
default:
// Claude primary, Gemini fallback
return claudeResult?.success ? claudeResult : geminiResult || claudeResult;
}
}
/**
* Build prompt for subagent
* @param {string} agentId - Agent identifier
* @param {Object} task - Task to execute
* @param {Object} context - Context
* @returns {string} - Formatted prompt
*/
buildPrompt(agentId, task, context) {
let prompt = `You are ${agentId}, a specialized agent in the AIOS framework.\n\n`;
prompt += '## Task\n';
prompt += `**ID:** ${task.id}\n`;
prompt += `**Description:** ${task.description}\n\n`;
if (task.acceptanceCriteria) {
prompt += '## Acceptance Criteria\n';
const criteria = Array.isArray(task.acceptanceCriteria)
? task.acceptanceCriteria
: [task.acceptanceCriteria];
criteria.forEach((ac, i) => {
prompt += `${i + 1}. ${ac}\n`;
});
prompt += '\n';
}
if (task.files && task.files.length > 0) {
prompt += '## Files to Modify\n';
task.files.forEach((f) => {
prompt += `- \`${f}\`\n`;
});
prompt += '\n';
}
if (context.gotchas && context.gotchas.length > 0) {
prompt += '## Active Gotchas (Avoid These Mistakes)\n';
context.gotchas.forEach((g) => {
prompt += `⚠️ **${g.title || g.pattern}**: ${g.workaround || g.description}\n`;
});
prompt += '\n';
}
if (context.patterns && context.patterns.length > 0) {
prompt += '## Suggested Patterns\n';
context.patterns.slice(0, 3).forEach((p) => {
prompt += `- ${p.name || p}: ${p.description || ''}\n`;
});
prompt += '\n';
}
prompt += '## Instructions\n';
prompt += '1. Implement the task completely\n';
prompt += '2. Follow existing patterns in the codebase\n';
prompt += '3. After completing, verify your changes work\n';
prompt += '4. Respond with a summary of what you did\n';
return prompt;
}
/**
* Execute prompt via Claude CLI
* @param {string} prompt - Prompt to execute
* @returns {Promise<Object>} - Execution result
*/
executeClaude(prompt) {
return new Promise((resolve, reject) => {
const args = ['--print', '--dangerously-skip-permissions'];
const escapedPrompt = prompt.replace(/'/g, "'\\''");
const fullCommand = `echo '${escapedPrompt}' | claude ${args.join(' ')}`;
const child = spawn('sh', ['-c', fullCommand], {
cwd: this.rootPath,
env: { ...process.env },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve({
success: true,
output: stdout,
filesModified: this.extractModifiedFiles(stdout),
});
} else {
reject(new Error(`Claude CLI exited with code ${code}: ${stderr || stdout}`));
}
});
child.on('error', (error) => {
reject(error);
});
});
}
/**
* Extract modified files from Claude output
* @param {string} output - Claude output
* @returns {Array<string>} - List of modified files
*/
extractModifiedFiles(output) {
const files = [];
// Match common patterns
const patterns = [
/(?:created|modified|updated|wrote|edited).*?[`']([^`']+)[`']/gi,
/(?:file|path):\s*[`']?([^\s`']+\.[a-z]+)[`']?/gi,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(output)) !== null) {
const file = match[1];
if ((!files.includes(file) && file.includes('/')) || file.includes('.')) {
files.push(file);
}
}
}
return files;
}
/**
* Log an event
* @param {string} type - Event type
* @param {Object} data - Event data
*/
log(type, data) {
const entry = {
type,
timestamp: new Date().toISOString(),
...data,
};
this.dispatchLog.push(entry);
if (this.dispatchLog.length > this.maxLogSize) {
this.dispatchLog.shift();
}
}
/**
* Get dispatch log
* @param {number} limit - Max entries
* @returns {Array} - Log entries
*/
getLog(limit = 20) {
return this.dispatchLog.slice(-limit);
}
/**
* Sleep for milliseconds
* @param {number} ms - Milliseconds
* @returns {Promise<void>}
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Get agent mapping
* @returns {Object} - Current agent mapping
*/
getAgentMapping() {
return { ...this.agentMapping };
}
/**
* Update agent mapping
* @param {Object} mapping - New mappings to add/update
*/
updateAgentMapping(mapping) {
this.agentMapping = { ...this.agentMapping, ...mapping };
}
/**
* Format status for CLI
* @returns {string} - Formatted status
*/
formatStatus() {
const recentDispatches = this.getLog(10);
let output = '📤 Subagent Dispatcher Status\n';
output += '━'.repeat(40) + '\n\n';
// Multi-provider status (Story GEMINI-INT.3)
output += '**AI Providers:**\n';
output += ` Multi-Provider: ${this.multiProviderEnabled ? '✅ Enabled' : '❌ Disabled'}\n`;
output += ` Default: ${this.defaultProvider}\n`;
output += ` Parallel Mode: ${this.parallelMode}\n\n`;
output += '**Agent Mapping:**\n';
const agents = [...new Set(Object.values(this.agentMapping))];
for (const agent of agents) {
const types = Object.entries(this.agentMapping)
.filter(([_, a]) => a === agent)
.map(([t]) => t);
const provider = this.providerMapping[agent] || 'auto';
output += ` ${agent} (${provider}): ${types.slice(0, 3).join(', ')}${types.length > 3 ? '...' : ''}\n`;
}
output += '\n';
if (recentDispatches.length > 0) {
output += '**Recent Dispatches:**\n';
for (const dispatch of recentDispatches.slice(-5)) {
const icon = dispatch.success === true ? '✅' : dispatch.success === false ? '❌' : '🔄';
const providerIcon = dispatch.provider === 'gemini' ? '🔷' : '🟣';
output += ` ${icon} ${providerIcon} ${dispatch.taskId || 'N/A'} → ${dispatch.agentId || 'N/A'}`;
if (dispatch.duration) output += ` (${dispatch.duration}ms)`;
output += '\n';
}
}
return output;
}
/**
* Get provider statistics (Story GEMINI-INT.3)
* @returns {Object} - Provider usage stats
*/
getProviderStats() {
const dispatches = this.getLog(100);
const stats = {
claude: { total: 0, success: 0, failed: 0, avgDuration: 0 },
gemini: { total: 0, success: 0, failed: 0, avgDuration: 0 },
};
for (const dispatch of dispatches) {
if (dispatch.type !== 'dispatch_completed' && dispatch.type !== 'dispatch_failed') continue;
const provider = dispatch.provider || 'claude';
if (!stats[provider]) continue;
stats[provider].total++;
if (dispatch.success) {
stats[provider].success++;
} else {
stats[provider].failed++;
}
if (dispatch.duration) {
const current = stats[provider].avgDuration * (stats[provider].total - 1);
stats[provider].avgDuration = (current + dispatch.duration) / stats[provider].total;
}
}
return stats;
}
}
module.exports = SubagentDispatcher;
module.exports.SubagentDispatcher = SubagentDispatcher;