@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
JavaScript
/**
* 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