claude-flow-novice
Version:
Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.
546 lines (545 loc) • 20.2 kB
JavaScript
/**
* Agent Spawner Module
*
* High-level API for spawning agents with full type safety,
* environment variable handling, provider configuration parsing,
* and Redis coordination integration.
*
* Usage:
* const spawner = new AgentSpawner();
* const result = await spawner.spawnAgent({
* agentType: 'backend-developer',
* taskId: 'task-123',
* iteration: 1,
* mode: 'standard'
* });
*/ import { existsSync, readFileSync } from 'fs';
import { resolve } from 'path';
import { spawn as childSpawn } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import { getEnvValue, getNetworkName } from '../lib/environment-contract';
/**
* AgentSpawner class - Type-safe agent spawning with validation
*/ export class AgentSpawner {
projectRoot;
agentProfilesDir;
agentConfigFile;
constructor(projectRoot){
this.projectRoot = projectRoot || process.cwd();
this.agentProfilesDir = resolve(this.projectRoot, '.claude/agents/cfn-dev-team');
this.agentConfigFile = resolve(this.projectRoot, '.claude/cfn-config/team-providers.json');
}
/**
* Spawn an agent with the given configuration
*/ async spawnAgent(config) {
const timestamp = new Date().toISOString();
const agentId = this.generateAgentId(config.agentType);
try {
// Validate configuration
this.validateSpawnConfig(config);
// Check agent exists
const agentExists = await this.validateAgentExists(config.agentType);
if (!agentExists) {
return {
agentId,
pid: -1,
status: 'failed',
timestamp,
error: `Agent type not found: ${config.agentType}`
};
}
// Parse provider configuration if not explicitly provided
let provider = config.provider;
let model = config.model;
if (!provider || !model) {
const providerConfig = await this.parseAgentProvider(config.agentType);
provider = provider || providerConfig.provider;
model = model || providerConfig.model;
}
// Build environment variables
const env = this.buildEnvironment(config, agentId, provider, model);
// Spawn the process
const pid = await this.spawnProcess(config.agentType, env, config.background);
return {
agentId,
pid,
status: 'spawned',
timestamp,
metadata: {
agentType: config.agentType,
taskId: config.taskId,
iteration: config.iteration,
mode: config.mode,
provider,
model
}
};
} catch (error) {
return {
agentId,
pid: -1,
status: 'failed',
timestamp,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Spawn a worker with team configuration
*/ async spawnWorker(config) {
const timestamp = new Date().toISOString();
const workerId = this.generateWorkerId(config.team);
try {
// Validate worker configuration
this.validateWorkerConfig(config);
// Load team provider configuration
const teamConfig = this.loadTeamConfig(config.team);
// Select model based on complexity
const model = this.selectModel(config.team, config.complexity);
// Get API key from environment
const apiKey = this.getApiKey(config.team, 'workers');
// Build environment for worker
const env = this.buildWorkerEnvironment(config, workerId, model, apiKey);
// Provider routing
this.routeWorkerProvider(config.providerMode, config.team, model, apiKey);
return {
agentId: workerId,
pid: 0,
status: 'spawned',
timestamp,
metadata: {
team: config.team,
complexity: config.complexity,
model,
provider: config.providerMode
}
};
} catch (error) {
return {
agentId: workerId,
pid: -1,
status: 'failed',
timestamp,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Validate that an agent type exists in the profiles directory
*/ async validateAgentExists(agentType) {
// Normalize agent type (handle both underscore and hyphen variations)
const normalized = agentType.replace(/_/g, '-').toLowerCase();
// Check for agent profile in subdirectories
const possiblePaths = [
resolve(this.agentProfilesDir, `${normalized}.md`),
// Check in subdirectories
...this.findAgentInSubdirs(normalized)
];
return possiblePaths.some((path)=>existsSync(path));
}
/**
* Parse provider configuration from agent profile frontmatter
*/ async parseAgentProvider(agentType) {
const agentPath = this.findAgentProfile(agentType);
if (!agentPath) {
return {
provider: 'zai',
model: 'glm-4.6'
};
}
try {
const content = readFileSync(agentPath, 'utf-8');
const providerMatch = content.match(/<!-- PROVIDER_PARAMETERS\s*([\s\S]*?)\s*-->/);
if (providerMatch) {
const params = providerMatch[1];
const providerMatch_ = params.match(/provider:\s*(\w+)/);
const modelMatch = params.match(/model:\s*([^\n]+)/);
return {
provider: providerMatch_?.[1] || 'zai',
model: modelMatch?.[1]?.trim() || 'glm-4.6'
};
}
} catch (error) {
// Silent fallback
}
return {
provider: 'zai',
model: 'glm-4.6'
};
}
/**
* Generate a unique agent ID
*/ generateAgentId(agentType) {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 9);
return `agent-${agentType}-${timestamp}-${random}`;
}
/**
* Generate a unique worker ID
*/ generateWorkerId(team) {
const uuid = uuidv4().substring(0, 8);
return `worker-${team}-${uuid}`;
}
/**
* Validate spawn configuration
*/ validateSpawnConfig(config) {
const errors = [];
if (!config.agentType || typeof config.agentType !== 'string') {
errors.push('agentType must be a non-empty string');
}
if (!config.taskId || typeof config.taskId !== 'string') {
errors.push('taskId must be a non-empty string');
} else {
const taskIdValidation = this.validateTaskId(config.taskId);
if (!taskIdValidation.valid) {
errors.push(taskIdValidation.error || 'Invalid taskId format');
}
}
if (config.iteration === undefined || typeof config.iteration !== 'number') {
errors.push('iteration must be a number');
}
if (![
'mvp',
'standard',
'enterprise'
].includes(config.mode)) {
errors.push('mode must be mvp, standard, or enterprise');
}
if (errors.length > 0) {
throw new Error(`Configuration validation failed:\n${errors.join('\n')}`);
}
}
/**
* Validate worker configuration
*/ validateWorkerConfig(config) {
const errors = [];
if (!config.team || typeof config.team !== 'string') {
errors.push('team must be a non-empty string');
}
if (![
'simple',
'complex'
].includes(config.complexity)) {
errors.push('complexity must be simple or complex');
}
if (![
'auto',
'zai',
'anthropic'
].includes(config.providerMode)) {
errors.push('providerMode must be auto, zai, or anthropic');
}
if (errors.length > 0) {
throw new Error(`Worker configuration validation failed:\n${errors.join('\n')}`);
}
}
/**
* Validate task ID format (CVSS 8.9 - command injection prevention)
* Supports both raw IDs and Phase 1 prefixed IDs (cli:*, trigger:*)
* Pattern: alphanumeric, underscore, hyphen, dot, and colon (for mode prefix) only, max 128 chars
*
* Accepted formats:
* - Raw: task-123 (16 chars)
* - Prefixed: cli:task-123 (20 chars)
* - Prefixed: trigger:task-123 (24 chars)
*/ validateTaskId(taskId) {
if (typeof taskId !== 'string' || taskId.length === 0) {
return {
valid: false,
error: 'Task ID must be a non-empty string'
};
}
// Updated pattern to support namespace prefixes (e.g., cli:, trigger:, task:, orchestrator:)
const taskIdPattern = /^([a-z]+:)?[a-zA-Z0-9_.-]{1,64}$/;
if (!taskIdPattern.test(taskId)) {
return {
valid: false,
error: 'Invalid task ID format - must contain optional namespace prefix (e.g., "cli:") and alphanumeric characters, dot, underscore, hyphens (max 64 chars)'
};
}
return {
valid: true
};
}
/**
* Build environment variables for agent execution
*
* Provider routing: Sets both PROVIDER and CLAUDE_API_PROVIDER for compatibility.
* - PROVIDER: New convention used by agent-spawner
* - CLAUDE_API_PROVIDER: Legacy convention used by anthropic-client.ts getAPIConfig()
*
* BUG FIX: Previously only set PROVIDER, but anthropic-client.ts checked CLAUDE_API_PROVIDER
*/ buildEnvironment(config, agentId, provider, model) {
const env = {
...process.env,
AGENT_ID: agentId,
AGENT_TYPE: config.agentType,
TASK_ID: config.taskId,
ITERATION: String(config.iteration),
MODE: config.mode,
// Provider routing - set both for compatibility
PROVIDER: provider,
CLAUDE_API_PROVIDER: provider,
MODEL: model,
SPAWNED_AT: new Date().toISOString(),
PROJECT_ROOT: this.projectRoot,
// Redis coordination for CLI mode agents (resolved via environment contract)
CFN_REDIS_HOST: getEnvValue('redis_host', 'cli'),
CFN_REDIS_PORT: getEnvValue('redis_port', 'cli'),
// FIX: Don't use REDIS_PASSWORD from parent env - only explicit CFN_REDIS_PASSWORD
// This prevents CLI agents from inheriting the wrong password from shell environment
CFN_REDIS_PASSWORD: process.env.CFN_REDIS_PASSWORD || '',
CFN_NETWORK_NAME: getNetworkName('cli')
};
// Add provider-specific API keys if available
// This ensures spawned agents have access to the correct API key for their provider
if (provider === 'zai' && process.env.ZAI_API_KEY) {
env.ZAI_API_KEY = process.env.ZAI_API_KEY;
}
if (provider === 'kimi' && process.env.KIMI_API_KEY) {
env.KIMI_API_KEY = process.env.KIMI_API_KEY;
}
if (provider === 'openrouter' && process.env.OPENROUTER_API_KEY) {
env.OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
}
if (provider === 'anthropic' && process.env.ANTHROPIC_API_KEY) {
env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
}
// Add optional prompt parameter if provided
if (config.prompt) {
env.PROMPT = config.prompt;
}
// Merge user-provided environment variables
if (config.env) {
Object.assign(env, config.env);
}
// Debug logging for provider routing
console.error(`[agent-spawner] Building env for agent ${agentId}:`);
console.error(`[agent-spawner] PROVIDER=${provider}, CLAUDE_API_PROVIDER=${provider}`);
console.error(`[agent-spawner] Has ZAI_API_KEY: ${!!env.ZAI_API_KEY}`);
console.error(`[agent-spawner] Has KIMI_API_KEY: ${!!env.KIMI_API_KEY}`);
console.error(`[agent-spawner] Has ANTHROPIC_API_KEY: ${!!env.ANTHROPIC_API_KEY}`);
return env;
}
/**
* Build environment for worker process
*/ buildWorkerEnvironment(config, workerId, model, apiKey) {
const env = {
...process.env,
WORKER_ID: workerId,
TEAM: config.team,
COMPLEXITY: config.complexity,
MODEL: model,
API_KEY: apiKey,
PROJECT_ROOT: this.projectRoot
};
if (config.agentType) {
env.AGENT_TYPE = config.agentType;
}
if (config.taskContext) {
env.TASK_CONTEXT = config.taskContext;
}
return env;
}
/**
* Spawn the actual process
*/ async spawnProcess(agentType, env, background = true) {
const args = [
'src/cli/agent-executor.ts',
'--agent-type',
agentType
];
const child = childSpawn('tsx', args, {
env,
stdio: background ? 'ignore' : 'inherit',
detached: background
});
const pid = child.pid || 0;
if (background) {
child.unref();
} else {
await new Promise((resolve, reject)=>{
child.on('exit', (code)=>{
if (code !== 0) {
reject(new Error(`Process exited with code ${code}`));
} else {
resolve(undefined);
}
});
});
}
return pid;
}
/**
* Find agent profile in directory structure
*/ findAgentProfile(agentType) {
const normalized = agentType.replace(/_/g, '-').toLowerCase();
// Direct path
const directPath = resolve(this.agentProfilesDir, `${normalized}.md`);
if (existsSync(directPath)) {
return directPath;
}
// Search in subdirectories
const subdirs = [
'developers',
'testers',
'reviewers',
'architecture',
'dev-ops',
'product-owners',
'coordinators',
'analysts',
'utility'
];
for (const subdir of subdirs){
const path = resolve(this.agentProfilesDir, subdir, `${normalized}.md`);
if (existsSync(path)) {
return path;
}
// Check nested subdirectories
const nestedDirs = [
'frontend',
'backend',
'database',
'quality',
'e2e',
'unit',
'validation',
'data'
];
for (const nested of nestedDirs){
const nestedPath = resolve(this.agentProfilesDir, subdir, nested, `${normalized}.md`);
if (existsSync(nestedPath)) {
return nestedPath;
}
}
}
return null;
}
/**
* Find agent in subdirectories
*/ findAgentInSubdirs(normalized) {
const paths = [];
const subdirs = [
'developers',
'testers',
'reviewers',
'architecture',
'dev-ops',
'product-owners',
'coordinators',
'analysts',
'utility'
];
for (const subdir of subdirs){
const path = resolve(this.agentProfilesDir, subdir, `${normalized}.md`);
paths.push(path);
// Check nested directories
const nestedDirs = [
'frontend',
'backend',
'database',
'quality',
'e2e',
'unit',
'validation',
'data'
];
for (const nested of nestedDirs){
paths.push(resolve(this.agentProfilesDir, subdir, nested, `${normalized}.md`));
}
}
return paths;
}
/**
* Load team configuration from config file
*/ loadTeamConfig(team) {
if (!existsSync(this.agentConfigFile)) {
throw new Error(`Team configuration not found at ${this.agentConfigFile}`);
}
const content = readFileSync(this.agentConfigFile, 'utf-8');
const config = JSON.parse(content);
if (!config.teams || !config.teams[team]) {
throw new Error(`Invalid or missing provider configuration for team: ${team}`);
}
return config.teams[team];
}
/**
* Select model based on complexity
*/ selectModel(team, complexity) {
const config = this.loadTeamConfig(team);
const workers = config.workers;
const models = workers?.models;
if (models && models[complexity]) {
return models[complexity];
}
// Fallback to default complexity
const defaultComplexity = 'simple';
if (models && models[defaultComplexity]) {
return models[defaultComplexity];
}
return 'gpt-4';
}
/**
* Get API key from environment
*/ getApiKey(team, role) {
const config = this.loadTeamConfig(team);
const roleConfig = config[role];
if (!roleConfig) {
throw new Error(`No configuration found for team=${team}, role=${role}`);
}
const apiKeyEnvVar = roleConfig.apiKeyEnvVar;
if (!apiKeyEnvVar) {
throw new Error(`apiKeyEnvVar not configured for team=${team}, role=${role}`);
}
const apiKey = process.env[apiKeyEnvVar];
if (!apiKey) {
throw new Error(`API key not found in environment variable: ${apiKeyEnvVar}`);
}
return apiKey;
}
/**
* Route worker to appropriate provider
*/ routeWorkerProvider(providerMode, team, model, apiKey) {
const config = this.loadTeamConfig(team);
const workers = config.workers;
const provider = workers?.provider;
const baseUrl = workers?.baseUrl;
switch(providerMode){
case 'auto':
this.routeAutoProvider(provider, baseUrl, model, apiKey);
break;
case 'zai':
process.env.ZAI_API_KEY = apiKey;
process.env.ZAI_BASE_URL = baseUrl;
process.env.ZAI_MODEL = model;
break;
case 'anthropic':
process.env.ANTHROPIC_API_KEY = apiKey;
process.env.ANTHROPIC_BASE_URL = baseUrl;
process.env.ANTHROPIC_MODEL = model;
break;
default:
throw new Error(`Invalid provider mode: ${providerMode}`);
}
}
/**
* Route to appropriate provider based on configuration
*/ routeAutoProvider(provider, baseUrl, model, apiKey) {
switch(provider){
case 'zai':
process.env.ZAI_API_KEY = apiKey;
process.env.ZAI_BASE_URL = baseUrl;
process.env.ZAI_MODEL = model;
break;
case 'anthropic':
process.env.ANTHROPIC_API_KEY = apiKey;
process.env.ANTHROPIC_BASE_URL = baseUrl;
process.env.ANTHROPIC_MODEL = model;
break;
default:
throw new Error(`Unsupported provider: ${provider}`);
}
}
}
//# sourceMappingURL=agent-spawner.js.map