UNPKG

@dollhousemcp/mcp-server

Version:

DollhouseMCP - A Model Context Protocol (MCP) server that enables dynamic AI persona management from markdown files, allowing Claude and other compatible AI assistants to activate and switch between different behavioral personas.

1,103 lines 257 kB
/** * Ensemble Element - Orchestrates multiple elements working together * * Ensembles allow combining multiple elements (personas, skills, templates, agents, memories) * into cohesive units with controlled activation, conflict resolution, and shared context. * * ARCHITECTURE: * - Extends BaseElement for standard element behavior * - Pure business logic (no file operations) * - Portfolio-agnostic (PortfolioManager passed to activate()) * * SECURITY MEASURES: * 1. Circular dependency detection with DFS algorithm * 2. Resource limits (max elements, nesting depth, activation time) * 3. Input sanitization for all user-provided data * 4. Activation timeout protection * 5. Context size limits to prevent memory exhaustion * 6. Audit logging for security events * 7. Condition validation to prevent code injection */ import { BaseElement } from '../BaseElement.js'; import { ElementStatus } from '../../types/elements/index.js'; import { ElementType } from '../../portfolio/types.js'; import * as vm from 'vm'; import { sanitizeInput } from '../../security/InputValidator.js'; import { UnicodeValidator } from '../../security/validators/unicodeValidator.js'; import { SecurityMonitor } from '../../security/securityMonitor.js'; import { logger } from '../../utils/logger.js'; import { ENSEMBLE_DEFAULTS, ACTIVATION_STRATEGIES, CONFLICT_STRATEGIES, ELEMENT_ROLES, ENSEMBLE_SECURITY_EVENTS, ENSEMBLE_ERRORS, ENSEMBLE_PATTERNS, DANGEROUS_CONDITION_PATTERNS, getEffectiveLimits } from './constants.js'; /** * Ensemble class - Orchestrates multiple elements as a cohesive unit * * Extends BaseElement to inherit: * - Standard validation * - Serialization * - Metadata management * - ID generation * - Status tracking */ export class Ensemble extends BaseElement { // instructions and content inherited from BaseElement (v2.0 dual-field architecture) // Element management - using array for single source of truth elements = []; elementInstances = new Map(); MAX_INSTANCE_CACHE_SIZE = 100; instanceAccessTimes = new Map(); // Shared context for inter-element communication sharedContext; // Activation state activationInProgress = false; lastActivationResult; // Activation metrics for performance monitoring activationMetrics = { totalActivations: 0, successfulActivations: 0, failedActivations: 0, averageDuration: 0, minDuration: Infinity, maxDuration: 0, nestedEnsembleCount: 0, maxNestingDepth: 0 }; // Cached effective limits (computed once per ensemble, respects per-ensemble overrides) _effectiveLimits = null; /** * Get effective limits for this ensemble * * Resolves limits in priority order: * 1. Per-ensemble overrides (from metadata.resourceLimits) * 2. Global configuration (setGlobalEnsembleLimits()) * 3. Environment variables (ENSEMBLE_MAX_*) * 4. Default values * * Results are cached for performance. Call invalidateLimitsCache() to refresh. * * @returns Resolved limits object */ getEffectiveLimits() { if (this._effectiveLimits === null) { // Convert ResourceLimits (per-ensemble API) to EnsembleLimitsConfig (internal config format) // // Field name mapping (historical reasons - ResourceLimits predates configurable limits): // ResourceLimits.maxActiveElements -> EnsembleLimitsConfig.maxElements // ResourceLimits.maxExecutionTimeMs -> EnsembleLimitsConfig.maxActivationTime // Other fields have matching names (maxNestingDepth, maxContextSize, etc.) const overrides = {}; const resourceLimits = this.metadata.resourceLimits; if (resourceLimits) { if (resourceLimits.maxActiveElements !== undefined) { overrides.maxElements = resourceLimits.maxActiveElements; } if (resourceLimits.maxExecutionTimeMs !== undefined) { overrides.maxActivationTime = resourceLimits.maxExecutionTimeMs; } if (resourceLimits.maxNestingDepth !== undefined) { overrides.maxNestingDepth = resourceLimits.maxNestingDepth; } if (resourceLimits.maxContextSize !== undefined) { overrides.maxContextSize = resourceLimits.maxContextSize; } if (resourceLimits.maxContextValueSize !== undefined) { overrides.maxContextValueSize = resourceLimits.maxContextValueSize; } if (resourceLimits.maxDependencies !== undefined) { overrides.maxDependencies = resourceLimits.maxDependencies; } if (resourceLimits.maxConditionLength !== undefined) { overrides.maxConditionLength = resourceLimits.maxConditionLength; } } this._effectiveLimits = getEffectiveLimits(overrides); } return this._effectiveLimits; } /** * Invalidate cached limits (call when resourceLimits changes) */ invalidateLimitsCache() { this._effectiveLimits = null; } constructor(metadata, elements = [], metadataService) { // SECURITY: Sanitize all inputs // NOTE: We preserve empty strings so validation can catch them const sanitizedMetadata = { ...metadata, name: metadata.name !== undefined ? (metadata.name === '' ? '' : // Preserve empty string for validation sanitizeInput(UnicodeValidator.normalize(metadata.name).normalizedContent, 100)) : undefined, description: metadata.description !== undefined ? (metadata.description === '' ? '' : // Preserve empty string for validation sanitizeInput(UnicodeValidator.normalize(metadata.description).normalizedContent, 500)) : undefined }; // Validate activation strategy const strategy = metadata.activationStrategy || ENSEMBLE_DEFAULTS.ACTIVATION_STRATEGY; if (!ACTIVATION_STRATEGIES.includes(strategy)) { throw new Error(`${ENSEMBLE_ERRORS.INVALID_STRATEGY}: ${strategy}. Valid strategies: ${ACTIVATION_STRATEGIES.join(', ')}`); } // Validate conflict resolution strategy const conflictRes = metadata.conflictResolution || ENSEMBLE_DEFAULTS.CONFLICT_RESOLUTION; if (!CONFLICT_STRATEGIES.includes(conflictRes)) { throw new Error(`${ENSEMBLE_ERRORS.INVALID_CONFLICT_RESOLUTION}: ${conflictRes}. Valid strategies: ${CONFLICT_STRATEGIES.join(', ')}`); } // Call BaseElement constructor super(ElementType.ENSEMBLE, sanitizedMetadata, metadataService); // Initialize ensemble-specific metadata // NOTE: We restore the original name even if empty, so validation can catch it // NOTE: resourceLimits is optional - per-ensemble overrides are resolved via getEffectiveLimits() this.metadata = { ...this.metadata, name: sanitizedMetadata.name !== undefined ? sanitizedMetadata.name : this.metadata.name, activationStrategy: strategy, conflictResolution: conflictRes, contextSharing: metadata.contextSharing || ENSEMBLE_DEFAULTS.CONTEXT_SHARING, resourceLimits: metadata.resourceLimits, // Optional per-ensemble overrides allowNested: metadata.allowNested ?? ENSEMBLE_DEFAULTS.ALLOW_NESTED, maxNestingDepth: metadata.maxNestingDepth || ENSEMBLE_DEFAULTS.MAX_NESTING_DEPTH, elements: [] }; // Initialize shared context this.sharedContext = { values: new Map(), owners: new Map(), timestamps: new Map() }; // Initialize extensions for tracking this.extensions = { elementCount: 0, activationStrategy: strategy, conflictResolution: conflictRes, lastActivation: null }; // Add elements from metadata if (elements.length > 0) { this.loadElementsFromMetadata(elements); } // SECURITY: Log ensemble creation SecurityMonitor.logSecurityEvent({ type: 'ELEMENT_CREATED', severity: 'LOW', source: 'Ensemble.constructor', details: `Ensemble created: ${this.metadata.name}`, additionalData: { elementCount: this.elements.length, activationStrategy: this.metadata.activationStrategy, conflictResolution: this.metadata.conflictResolution } }); } // ==================== ARRAY HELPER METHODS ==================== /** * Find an element by name in the elements array * @param name - Element name to search for * @returns Element if found, undefined otherwise */ findElementByName(name) { return this.elements.find(el => el.element_name === name); } /** * Check if an element exists in the array * @param name - Element name to check * @returns true if element exists, false otherwise */ hasElement(name) { return this.elements.some(el => el.element_name === name); } /** * Remove an element from the array by name * @param name - Element name to remove * @returns true if element was removed, false if not found */ removeElementByName(name) { const index = this.elements.findIndex(el => el.element_name === name); if (index !== -1) { this.elements.splice(index, 1); return true; } return false; } /** * Add or update an element in the array * @param element - Element to add or update * @returns true if element was added, false if updated */ setElement(element) { const index = this.elements.findIndex(el => el.element_name === element.element_name); if (index !== -1) { // Update existing element this.elements[index] = element; return false; } else { // Add new element this.elements.push(element); return true; } } /** * Sync elements array to metadata (single source of truth) */ syncElementsToMetadata() { this.metadata.elements = [...this.elements]; this.extensions.elementCount = this.elements.length; } /** * Sync the internal elements array from metadata.elements * * Use this after metadata.elements has been modified externally * (e.g., via editElement) to ensure internal state is consistent. */ syncElementsFromMetadata() { // Clear existing elements this.elements = []; // Issue #658: Defensive guard — metadata.elements must be an array. // If a dict leaked through (e.g., from deepMerge), log warning and bail out // rather than silently producing an empty elements array. if (!Array.isArray(this.metadata.elements)) { if (this.metadata.elements && typeof this.metadata.elements === 'object') { logger.warn('[Ensemble] metadata.elements is not an array — possible dict format leak. Elements will not be loaded.'); } return; } // Reload from metadata if (this.metadata.elements.length > 0) { this.loadElementsFromMetadata(this.metadata.elements); } } // ==================== END ARRAY HELPER METHODS ==================== /** * Load element references from metadata * Called during construction to populate elements array */ loadElementsFromMetadata(elements) { const limits = this.getEffectiveLimits(); // SECURITY: Enforce element limit if (elements.length > limits.MAX_ELEMENTS) { throw new Error(`Ensemble cannot contain more than ${limits.MAX_ELEMENTS} elements (attempted: ${elements.length})`); } for (let i = 0; i < elements.length; i++) { const element = elements[i]; // Issue #507: Validate each element is a plain object before accessing properties. // When users pass strings (e.g., ["my-skill"]) instead of objects, the old code // produced confusing "Element 'undefined' has no element_type" errors. if (typeof element !== 'object' || element === null || Array.isArray(element)) { const valueType = element === null ? 'null' : Array.isArray(element) ? 'array' : typeof element; throw new Error(`Element at index ${i} is a ${valueType} ("${String(element).substring(0, 50)}"), ` + `but must be an object with { element_name, element_type }. ` + `Example: { element_name: "my-skill", element_type: "skill", role: "support", priority: 50, activation: "always" }`); } // Migrate legacy field names: name→element_name, type→element_type (mirrors // EnsembleManager.create() lines 586-602 to keep create and edit paths consistent) const elementName = element.element_name || element.name; const elementType = element.element_type || element.type; // Issue #466: Require explicit element_type — callers must resolve via portfolio // lookup (resolveEnsembleElementTypes) before reaching this method. if (!elementType) { throw new Error(`Element '${elementName}' has no element_type. ` + `Provide element_type explicitly or ensure the element exists in the portfolio.`); } // Validate and sanitize element, applying defaults for optional fields const sanitized = { element_name: sanitizeInput(elementName, 100), element_type: sanitizeInput(elementType, 50), role: element.role || ENSEMBLE_DEFAULTS.ELEMENT_ROLE, priority: element.priority ?? ENSEMBLE_DEFAULTS.PRIORITY, activation: element.activation || 'always', // Don't use sanitizeInput for conditions (it removes < > operators) condition: element.condition ? element.condition.trim().substring(0, limits.MAX_CONDITION_LENGTH) : undefined, dependencies: element.dependencies?.map(dep => sanitizeInput(dep, 100)), purpose: element.purpose ? sanitizeInput(element.purpose, 500) : undefined }; // Validate role if (!ELEMENT_ROLES.includes(sanitized.role)) { throw new Error(`${ENSEMBLE_ERRORS.INVALID_ELEMENT_ROLE}: ${sanitized.role}. Valid roles: ${ELEMENT_ROLES.join(', ')}`); } // Validate element name pattern if (!ENSEMBLE_PATTERNS.ELEMENT_NAME_PATTERN.test(sanitized.element_name)) { throw new Error(`Invalid element name format: ${sanitized.element_name}`); } this.setElement(sanitized); } this.syncElementsToMetadata(); } /** * Add an element to the ensemble * * @param element - Element configuration to add * @throws Error if element would create circular dependency or exceed limits */ addElement(element) { const limits = this.getEffectiveLimits(); // Check element limit (uses configurable limits) if (this.elements.length >= limits.MAX_ELEMENTS) { SecurityMonitor.logSecurityEvent({ type: ENSEMBLE_SECURITY_EVENTS.RESOURCE_LIMIT_EXCEEDED, severity: 'MEDIUM', source: 'Ensemble.addElement', details: `Maximum elements (${limits.MAX_ELEMENTS}) exceeded` }); throw new Error(`Ensemble cannot contain more than ${limits.MAX_ELEMENTS} elements`); } // Sanitize element name - first use general sanitization, then enforce pattern const originalName = element.element_name; let sanitizedName = sanitizeInput(originalName, 100); // Remove any characters that don't match the allowed pattern const cleanedName = sanitizedName.replace(/[^a-zA-Z0-9_-]/g, ''); // Check if invalid characters were removed if (cleanedName.length < originalName.length && originalName.length > 0) { throw new Error(`Element name contains invalid characters. ` + `Only alphanumeric characters, hyphens, and underscores are allowed. ` + `Invalid name: "${originalName}"`); } sanitizedName = cleanedName; // Reject if sanitization results in empty name if (!sanitizedName || sanitizedName.length === 0) { throw new Error(`Element name is empty or contains only invalid characters: "${originalName}"`); } // Final pattern validation (should always pass if above checks passed) if (!ENSEMBLE_PATTERNS.ELEMENT_NAME_PATTERN.test(sanitizedName)) { throw new Error(`Element name format validation failed: "${sanitizedName}". ` + `This should not happen - please report this error.`); } // Create sanitized copy to avoid mutating the input const sanitizedElement = { element_name: sanitizedName, element_type: element.element_type, role: element.role, priority: element.priority, activation: element.activation }; // Validate role if (!ELEMENT_ROLES.includes(sanitizedElement.role)) { throw new Error(`${ENSEMBLE_ERRORS.INVALID_ELEMENT_ROLE}: ${sanitizedElement.role}. Valid roles: ${ELEMENT_ROLES.join(', ')}`); } // Validate and sanitize activation condition if provided if (element.condition) { // Trim and enforce length limit (don't use sanitizeInput as it removes < > operators) const conditionToValidate = element.condition .trim() .substring(0, limits.MAX_CONDITION_LENGTH); if (!this.isValidCondition(conditionToValidate)) { SecurityMonitor.logSecurityEvent({ type: ENSEMBLE_SECURITY_EVENTS.SUSPICIOUS_CONDITION, severity: 'HIGH', source: 'Ensemble.addElement', details: `Suspicious activation condition: ${conditionToValidate}` }); throw new Error('Invalid activation condition syntax'); } sanitizedElement.condition = conditionToValidate; } // Validate and sanitize dependencies if (element.dependencies) { // Truncate dependencies array if it exceeds the limit (security: prevent resource exhaustion) let deps = element.dependencies; if (deps.length > limits.MAX_DEPENDENCIES) { deps = deps.slice(0, limits.MAX_DEPENDENCIES); SecurityMonitor.logSecurityEvent({ type: ENSEMBLE_SECURITY_EVENTS.RESOURCE_LIMIT_EXCEEDED, severity: 'MEDIUM', source: 'Ensemble.addElement', details: `Dependencies truncated from ${element.dependencies.length} to ${limits.MAX_DEPENDENCIES}` }); } // Sanitize each dependency const sanitizedDeps = deps.map(dep => sanitizeInput(dep, 100)); sanitizedElement.dependencies = sanitizedDeps; // Check for circular dependencies if (this.wouldCreateCircularDependency(sanitizedName, sanitizedDeps)) { const circular = this.findCircularDependency(sanitizedName, sanitizedDeps); SecurityMonitor.logSecurityEvent({ type: ENSEMBLE_SECURITY_EVENTS.CIRCULAR_DEPENDENCY, severity: 'HIGH', source: 'Ensemble.addElement', details: `Circular dependency detected: ${circular.path.join(' -> ')}` }); throw new Error(`${ENSEMBLE_ERRORS.CIRCULAR_DEPENDENCY}: ${circular.message}`); } } // Add optional purpose field if provided if (element.purpose) { sanitizedElement.purpose = sanitizeInput(element.purpose, 500); } // Add sanitized element to array (not the original to avoid storing unsanitized data) this.setElement(sanitizedElement); this.syncElementsToMetadata(); this.markDirty(); } /** * Update an element's configuration in the ensemble * * @param elementName - Name of element to update * @param updates - Partial element configuration to merge with existing * @throws Error if element not found or activation in progress */ updateElement(elementName, updates) { // Prevent updates during activation if (this.activationInProgress) { throw new Error('Cannot update elements while ensemble activation is in progress'); } const limits = this.getEffectiveLimits(); const sanitizedName = sanitizeInput(elementName, 100); if (!this.hasElement(sanitizedName)) { throw new Error(ENSEMBLE_ERRORS.ELEMENT_NOT_FOUND); } const currentElement = this.findElementByName(sanitizedName); // Sanitize and validate updates const sanitizedUpdates = {}; if (updates.role !== undefined) { if (!ELEMENT_ROLES.includes(updates.role)) { throw new Error(`${ENSEMBLE_ERRORS.INVALID_ELEMENT_ROLE}: ${updates.role}. Valid roles: ${ELEMENT_ROLES.join(', ')}`); } sanitizedUpdates.role = updates.role; } if (updates.priority !== undefined) { sanitizedUpdates.priority = updates.priority; } if (updates.activation !== undefined) { sanitizedUpdates.activation = updates.activation; } if (updates.condition !== undefined) { // Trim and enforce length limit (don't use sanitizeInput as it removes < > operators) const conditionToValidate = updates.condition .trim() .substring(0, limits.MAX_CONDITION_LENGTH); if (!this.isValidCondition(conditionToValidate)) { SecurityMonitor.logSecurityEvent({ type: ENSEMBLE_SECURITY_EVENTS.SUSPICIOUS_CONDITION, severity: 'HIGH', source: 'Ensemble.updateElement', details: `Suspicious activation condition: ${conditionToValidate}` }); throw new Error('Invalid activation condition syntax'); } sanitizedUpdates.condition = conditionToValidate; } if (updates.dependencies !== undefined) { // Validate dependency limit let deps = updates.dependencies; if (deps.length > limits.MAX_DEPENDENCIES) { deps = deps.slice(0, limits.MAX_DEPENDENCIES); SecurityMonitor.logSecurityEvent({ type: ENSEMBLE_SECURITY_EVENTS.RESOURCE_LIMIT_EXCEEDED, severity: 'MEDIUM', source: 'Ensemble.updateElement', details: `Dependencies truncated from ${updates.dependencies.length} to ${limits.MAX_DEPENDENCIES}` }); } // Sanitize dependencies const sanitizedDeps = deps.map(dep => sanitizeInput(dep, 100)); // Check for circular dependencies with updated dependencies if (this.wouldCreateCircularDependency(sanitizedName, sanitizedDeps)) { const circular = this.findCircularDependency(sanitizedName, sanitizedDeps); SecurityMonitor.logSecurityEvent({ type: ENSEMBLE_SECURITY_EVENTS.CIRCULAR_DEPENDENCY, severity: 'HIGH', source: 'Ensemble.updateElement', details: `Circular dependency detected: ${circular.path.join(' -> ')}` }); throw new Error(`${ENSEMBLE_ERRORS.CIRCULAR_DEPENDENCY}: ${circular.message}`); } sanitizedUpdates.dependencies = sanitizedDeps; } if (updates.purpose !== undefined) { sanitizedUpdates.purpose = sanitizeInput(updates.purpose, 500); } // Merge updates with current element const updatedElement = { ...currentElement, ...sanitizedUpdates }; this.setElement(updatedElement); this.syncElementsToMetadata(); this.markDirty(); logger.debug(`Element ${sanitizedName} updated in ensemble ${this.id}`); } /** * Remove an element from the ensemble * * @param elementName - Name of element to remove * @throws Error if element not found or activation in progress */ removeElement(elementName) { // Prevent removal during activation if (this.activationInProgress) { throw new Error('Cannot remove elements while ensemble activation is in progress'); } const sanitizedName = sanitizeInput(elementName, 100); if (!this.hasElement(sanitizedName)) { throw new Error(ENSEMBLE_ERRORS.ELEMENT_NOT_FOUND); } // Check if element is currently active if (this.elementInstances.has(sanitizedName)) { logger.warn(`Removing active element '${sanitizedName}' from ensemble. ` + `Consider deactivating the ensemble first.`); } // Remove element and its instance this.removeElementByName(sanitizedName); this.elementInstances.delete(sanitizedName); // Clean up dependencies pointing to this element for (const element of this.elements) { if (element.dependencies?.includes(sanitizedName)) { element.dependencies = element.dependencies.filter(dep => dep !== sanitizedName); } } // Clean up shared context owned by this element for (const [key, owner] of this.sharedContext.owners) { if (owner === sanitizedName) { this.sharedContext.values.delete(key); this.sharedContext.owners.delete(key); this.sharedContext.timestamps.delete(key); } } this.syncElementsToMetadata(); this.markDirty(); } /** * Activate the element (BaseElement interface implementation) * For ensembles, this is a no-op. Use activateEnsemble() for full activation. */ async activate() { this._status = ElementStatus.ACTIVE; logger.debug(`Ensemble ${this.id} activated (use activateEnsemble() for full orchestration)`); } /** * Activate the ensemble and all its elements based on strategy * * @param portfolioManager - Portfolio manager for loading element instances * @param managers - Element managers for loading different element types * @param nestingDepth - Current nesting depth (0 for top-level, increments for nested) * @returns Activation result with success status and element results */ async activateEnsemble(portfolioManager, managers, nestingDepth = 0) { if (this.activationInProgress) { throw new Error('Activation already in progress'); } this.activationInProgress = true; const startTime = Date.now(); const limits = this.getEffectiveLimits(); const timeout = limits.MAX_ACTIVATION_TIME; let timeoutHandle; try { // Create timeout promise const timeoutPromise = new Promise((_, reject) => { timeoutHandle = setTimeout(() => { reject(new Error(`Ensemble activation timeout after ${timeout}ms`)); }, timeout); }); // Create activation promise const activationPromise = this.performActivation(portfolioManager, managers, startTime, nestingDepth); // Race activation against timeout const result = await Promise.race([activationPromise, timeoutPromise]); return result; } catch (error) { this._status = ElementStatus.ERROR; SecurityMonitor.logSecurityEvent({ type: ENSEMBLE_SECURITY_EVENTS.ACTIVATION_FAILED, severity: 'HIGH', source: 'Ensemble.activateEnsemble', details: `Activation failed after ${Date.now() - startTime}ms: ${error instanceof Error ? error.message : 'Unknown error'}` }); throw error; } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); } this.activationInProgress = false; } } /** * Helper to create structured log context for ensemble operations * @private */ getLogContext(additionalData) { return { ensembleId: this.id, ensembleName: this.metadata.name, elementCount: this.elements.length, activationStrategy: this.metadata.activationStrategy, allowNested: this.metadata.allowNested, maxNestingDepth: this.metadata.maxNestingDepth, ...additionalData }; } /** * Perform the actual activation logic * Extracted to separate method for timeout handling */ async performActivation(portfolioManager, managers, startTime, nestingDepth) { // Set status this._status = ElementStatus.ACTIVE; // Structured logging: Activation start logger.info('Ensemble activation started', this.getLogContext({ strategy: this.metadata.activationStrategy, totalElements: this.elements.length, resourceLimits: this.metadata.resourceLimits, nestingDepth: nestingDepth })); const result = { success: true, activatedElements: [], failedElements: [], conflicts: [], totalDuration: 0, elementResults: [] }; // Get activation order based on strategy const activationOrder = this.getActivationOrder(); // Activate elements according to strategy switch (this.metadata.activationStrategy) { case 'sequential': await this.activateSequential(activationOrder, result, portfolioManager, managers, nestingDepth); break; case 'all': await this.activateAll(activationOrder, result, portfolioManager, managers, nestingDepth); break; case 'priority': await this.activatePriority(activationOrder, result, portfolioManager, managers, nestingDepth); break; case 'conditional': await this.activateConditional(activationOrder, result, portfolioManager, managers, nestingDepth); break; case 'lazy': // Lazy activation happens on-demand, just mark as ready logger.info(`Ensemble ${this.id} ready for lazy activation`); break; } result.totalDuration = Date.now() - startTime; this.lastActivationResult = result; this.extensions.lastActivation = new Date().toISOString(); // Count nested ensembles in results const nestedEnsembleCount = result.activatedElements.filter(name => { const elem = this.findElementByName(name); return elem?.element_type === 'ensemble'; }).length; // Update activation metrics with actual nesting depth this.updateActivationMetrics(result, nestingDepth); // Log activation result SecurityMonitor.logSecurityEvent({ type: 'ELEMENT_CREATED', // Generic element event (ensembles don't have custom security event types yet) severity: 'LOW', source: 'Ensemble.activate', details: `Ensemble ${this.metadata.name} activated: ${result.activatedElements.length} elements, ${result.failedElements.length} failures`, metadata: { nestedEnsembles: nestedEnsembleCount, duration: result.totalDuration, metrics: this.getActivationMetrics() } }); // Structured logging: Activation complete with metrics if (result.failedElements.length > 0) { logger.warn('Ensemble activation completed with failures', this.getLogContext({ activatedCount: result.activatedElements.length, failedCount: result.failedElements.length, failedElements: result.failedElements, nestedEnsembles: nestedEnsembleCount, duration: result.totalDuration, metrics: this.getActivationMetrics() })); result.success = false; } else { logger.info('Ensemble activation completed successfully', this.getLogContext({ activatedCount: result.activatedElements.length, nestedEnsembles: nestedEnsembleCount, duration: result.totalDuration, averageElementTime: result.totalDuration / Math.max(result.activatedElements.length, 1), metrics: this.getActivationMetrics() })); } return result; } /** * Deactivate the ensemble and all its elements */ async deactivate() { this._status = ElementStatus.INACTIVE; // Deactivate all element instances const deactivationPromises = []; for (const [elementName, instance] of this.elementInstances) { if (instance.deactivate) { deactivationPromises.push(instance.deactivate().catch(error => { logger.error(`Failed to deactivate element ${elementName}:`, error); })); } } await Promise.all(deactivationPromises); // Clear shared context this.sharedContext.values.clear(); this.sharedContext.owners.clear(); this.sharedContext.timestamps.clear(); logger.info(`Ensemble ${this.id} deactivated`); } /** * Validate the ensemble configuration * Overrides BaseElement.validate() to add ensemble-specific checks */ validate() { // Call base validation first const result = super.validate(); // Initialize arrays if they don't exist if (!result.errors) result.errors = []; if (!result.warnings) result.warnings = []; // Check for circular dependencies const circular = this.detectAllCircularDependencies(); if (circular.length > 0) { for (const cycle of circular) { result.errors.push({ field: 'dependencies', message: cycle.message, severity: 'high' }); } } // Validate element count if (this.elements.length === 0) { result.warnings.push({ field: 'elements', message: 'Ensemble has no elements', suggestion: 'Add elements using addElement()', severity: 'low' }); } // Check for orphaned dependencies for (const element of this.elements) { if (element.dependencies) { for (const dep of element.dependencies) { if (!this.hasElement(dep)) { result.errors.push({ field: `${element.element_name}.dependencies`, message: `Dependency '${dep}' not found in ensemble`, severity: 'medium' }); } } } } // Update valid flag based on errors result.valid = result.errors.length === 0; // Clean up empty arrays if (result.errors.length === 0) result.errors = undefined; if (result.warnings.length === 0) result.warnings = undefined; return result; } // ==================== PRIVATE METHODS ==================== /** * Determine activation order based on strategy */ getActivationOrder() { switch (this.metadata.activationStrategy) { case 'sequential': // Topological sort based on dependencies return this.topologicalSort(); case 'priority': { // Sort by priority (highest first) const prioritySorted = [...this.elements] .sort((a, b) => b.priority - a.priority); return prioritySorted.map(el => el.element_name); } case 'all': case 'conditional': case 'lazy': // All elements, natural order return this.elements.map(el => el.element_name); default: return this.elements.map(el => el.element_name); } } /** * Topological sort for dependency-based activation order */ topologicalSort() { const visited = new Set(); const order = []; const visit = (elementName) => { if (visited.has(elementName)) return; visited.add(elementName); const element = this.findElementByName(elementName); if (element?.dependencies) { for (const dep of element.dependencies) { if (this.hasElement(dep)) { visit(dep); } } } order.push(elementName); }; for (const element of this.elements) { visit(element.element_name); } return order; } /** * Activate elements sequentially in dependency order */ async activateSequential(order, result, portfolioManager, managers, nestingDepth) { for (const elementName of order) { await this.activateSingleElement(elementName, result, portfolioManager, managers, nestingDepth); } } /** * Activate all elements simultaneously */ async activateAll(order, result, portfolioManager, managers, nestingDepth) { const activationPromises = order.map(elementName => this.activateSingleElement(elementName, result, portfolioManager, managers, nestingDepth)); await Promise.all(activationPromises); } /** * Activate elements by priority (highest first) */ async activatePriority(order, result, portfolioManager, managers, nestingDepth) { // order is already sorted by priority from getActivationOrder() for (const elementName of order) { await this.activateSingleElement(elementName, result, portfolioManager, managers, nestingDepth); } } /** * Activate elements based on conditions */ async activateConditional(order, result, portfolioManager, managers, nestingDepth) { logger.warn(`Conditional activation strategy selected, but condition evaluation ` + `is not yet implemented. All conditional elements will be activated.`); // Track activation start time for context building const activationStartTime = Date.now(); for (const elementName of order) { const element = this.findElementByName(elementName); // Determine if element should be activated let shouldActivate = false; if (element.activation === 'always') { // Always activate elements with 'always' mode shouldActivate = true; } else if (element.activation === 'conditional' && element.condition) { // Evaluate condition for conditional elements shouldActivate = this.evaluateCondition(element.condition, element, activationStartTime, result); if (!shouldActivate) { logger.debug(`Skipping ${elementName}: condition not met`); } } else if (element.activation === 'conditional' && !element.condition) { // Conditional elements without a condition default to activated logger.debug(`${elementName}: conditional activation without condition, defaulting to true`); shouldActivate = true; } else if (element.activation === 'on-demand') { // On-demand elements are not activated automatically in conditional strategy logger.debug(`Skipping ${elementName}: on-demand activation mode`); shouldActivate = false; } else { // Default behavior for unspecified activation mode shouldActivate = true; } if (shouldActivate) { await this.activateSingleElement(elementName, result, portfolioManager, managers, nestingDepth); } } } /** * Activate a single element and track results */ async activateSingleElement(elementName, result, portfolioManager, managers, nestingDepth) { const startTime = Date.now(); const elementConfig = this.findElementByName(elementName); const isNestedEnsemble = elementConfig.element_type === 'ensemble'; try { // Load element instance if not already loaded if (!this.elementInstances.has(elementName)) { const instance = await this.loadElementInstance(elementConfig, portfolioManager, managers); this.elementInstances.set(elementName, instance); this.instanceAccessTimes.set(elementName, Date.now()); // Evict if cache is too large this.evictOldestInstance(); } else { // Update access time this.instanceAccessTimes.set(elementName, Date.now()); } const instance = this.elementInstances.get(elementName); // Activate via the type manager's activation method, which both sets the // instance status AND registers the element in the manager's active set. // Using instance.activate() alone only sets the status flag — the manager // won't know the element is active, so get_active_elements returns nothing. // @see Issue #1769 - Ensemble activation not registering with type managers const activated = await this.activateViaManager(elementName, elementConfig.element_type, managers); if (!activated) { // Fallback: activate the instance directly if no manager available if (instance.activate) { await instance.activate(); } } // If this is a nested ensemble, call activateEnsemble with incremented depth if (isNestedEnsemble && 'activateEnsemble' in instance && typeof instance.activateEnsemble === 'function') { await instance.activateEnsemble(portfolioManager, managers, nestingDepth + 1); } const duration = Date.now() - startTime; result.activatedElements.push(elementName); result.elementResults.push({ elementName, success: true, duration, isNestedEnsemble, nestingDepth: isNestedEnsemble ? nestingDepth + 1 : nestingDepth }); logger.debug(`Element ${elementName} activated in ${duration}ms`, { isNestedEnsemble, nestingDepth: isNestedEnsemble ? nestingDepth + 1 : nestingDepth }); } catch (error) { const duration = Date.now() - startTime; result.failedElements.push(elementName); result.elementResults.push({ elementName, success: false, duration, error: error, isNestedEnsemble, nestingDepth: isNestedEnsemble ? nestingDepth + 1 : nestingDepth }); logger.error(`Failed to activate element ${elementName}:`, error); } } /** * Activate an element via its type manager's activation method. * This registers the element in the manager's active set so that * get_active_elements correctly reports it. * * @returns true if activation went through a manager, false if no manager available * @see Issue #1769 - Ensemble activation not registering with type managers */ async activateViaManager(elementName, elementType, managers) { const normalized = this.normalizeElementType(elementType); switch (normalized) { case 'skill': { const mgr = managers.skillManager; if (mgr?.activateSkill) { await mgr.activateSkill(elementName); return true; } return false; } case 'persona': { const mgr = managers.personaManager; if (mgr?.activatePersona) { await mgr.activatePersona(elementName); return true; } return false; } case 'agent': { const mgr = managers.agentManager; if (mgr?.activateAgent) { await mgr.activateAgent(elementName); return true; } return false; } case 'memory': { const mgr = managers.memoryManager; if (mgr?.activateMemory) { await mgr.activateMemory(elementName); return true; } return false; } case 'ensemble': { const mgr = managers.ensembleManager; if (mgr?.activateEnsemble) { await mgr.activateEnsemble(elementName); return true; } return false; } case 'template': { // Templates don't have activation state — just activate the instance return false; } default: return false; } } /** * Load an element instance from portfolio */ async loadElementInstance(element, portfolioManager, managers) { const elementType = element.element_type.toLowerCase(); const isNestedEnsemble = elementType === 'ensemble' || elementType === 'ensembles'; // Structured logging: Element loading with nesting detection logger.debug('Loading element for ensemble', this.getLogContext({ elementName: element.element_name, elementType: elementType, isNestedEnsemble, role: element.role, priority: element.priority })); const normalizedType = this.normalizeElementType(elementType); const manager = this.getManagerForType(normalizedType, managers); if (!manager) { const ensembleContext = `in ensemble "${this.metadata.name}" (${this.id})`; const nestedHint = normalizedType === 'ensemble' ? '\n → For nested ensembles, ensure EnsembleManager is provided in managers parameter' : ''; throw new Error(`Failed to load element "${element.element_name}" of type "${normalizedType}" ${ensembleContext}.\n` + ` Reason: No manager available for element type "${normalizedType}".\n` + ` Available managers: ${this.getAvailableManagerTypes(managers).join(', ')}${nestedHint}`); } return await this.findElementInManager(manager, element.element_name, normalizedType); } /** * Normalize element type to singular form */ normalizeElementType(type) { const typeMap = { 'skills': 'skill', 'templates': 'template', 'agents': 'agent', 'memories': 'memory', 'personas': 'persona', 'ensembles': 'ensemble' }; return typeMap[type] || type; } /** * Get the appropriate manager for an element type */ getManagerForType(type, managers) { const managerMap = { 'skill': managers.skillManager, 'template': managers.templateManager, 'agent': managers.agentManager, 'memory': managers.memoryManager, 'persona': managers.personaManager, 'ensemble': managers.ensembleManager }; return managerMap[type]; } /** * Get list of available manager types */ getAvailableManagerTypes(managers) { const availableTypes = []; if (managers.skillManager) availableTypes.push('skill'); if (managers.templateManager) availableTypes.push('template'); if (managers.agentManager) availableTypes.push('agent'); if (managers.memoryManager) availableTypes.push('memory'); if (managers.personaManager) availableTypes.push('persona'); if (managers.ensembleManager) availableTypes.push('ensemble'); return avail