@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,039 lines • 387 kB
JavaScript
/**
* AgentManager - Refactored to extend BaseElementManager
* Manages agent CRUD operations, metadata sanitization, and state persistence.
*/
import * as path from 'path';
import { Agent } from './Agent.js';
import { COMMIT_PERSISTED_VERSION, AGENT_LIMITS, RISK_TOLERANCE_LEVELS, STEP_LIMIT_ACTIONS, EXECUTION_FAILURE_ACTIONS, BACKOFF_STRATEGIES, isOneOf, normalizeAutonomyKeys, normalizeResilienceKeys, normalizeGoalKeys, } from './constants.js';
import { DEFAULT_SAFETY_CONFIG, } from './types.js';
import { getGatheredData } from './gatheredData.js';
import { evaluateAutonomy } from './autonomyEvaluator.js';
import { isV1Agent, convertV1ToV2, } from './v1ToV2Converter.js';
import { determineSafetyTier, createVerificationChallenge, createConfirmationRequest, createDangerZoneOperation, createExecutionContext, } from './safetyTierService.js';
import { BaseElementManager } from '../base/BaseElementManager.js';
import { ElementType } from '../../portfolio/types.js';
import { toSingularLabel } from '../../utils/elementTypeNormalization.js';
import { sanitizeInput, validatePath } from '../../security/InputValidator.js';
import { UnicodeValidator } from '../../security/validators/unicodeValidator.js';
import { SecurityMonitor } from '../../security/securityMonitor.js';
import { ContentValidator } from '../../security/contentValidator.js';
import { InputNormalizer } from '../../security/InputNormalizer.js';
import { SafeRegex } from '../../security/dosProtection.js';
import { logger } from '../../utils/logger.js';
import { ElementMessages } from '../../utils/elementMessages.js';
import { ElementNotFoundError } from '../../utils/ErrorHandler.js';
import { sanitizeGatekeeperPolicy } from '../../handlers/mcp-aql/policies/ElementPolicies.js';
import { SECURITY_LIMITS } from '../../security/constants.js';
const AGENT_FILE_EXTENSION = '.md';
const STATE_DIRECTORY = '.state';
const STATE_FILE_EXTENSION = '.state.yaml';
const MAX_YAML_SIZE = 64 * 1024;
const MAX_FILE_SIZE = 100 * 1024;
// Issue #83: Centralized active element limits (configurable via env vars)
import { getActiveElementLimitConfig, getMaxActiveLimit } from '../../config/active-element-limits.js';
export class AgentManager extends BaseElementManager {
stateDir;
stateCache = new Map();
triggerValidationService;
validationService;
serializationService;
metadataService;
// Track active agents by name (stable identifier)
activeAgentNames = new Set();
// Static resolver for element manager lookup (DI pattern)
// This allows Agent instances to resolve managers without tight coupling
static elementManagerResolver;
// Issue #402: Static resolver for DangerZoneEnforcer (DI pattern)
static dangerZoneEnforcerResolver;
// Issue #142: Static resolver for VerificationStore (DI pattern)
static verificationStoreResolver;
constructor(portfolioManager, fileLockManager, baseDir, fileOperationsService, validationRegistry, serializationService, metadataService, fileWatchService, memoryBudget, backupService) {
const elementDirOverride = path.join(baseDir, ElementType.AGENT);
super(ElementType.AGENT, portfolioManager, fileLockManager, { elementDirOverride, fileWatchService, memoryBudget, backupService }, fileOperationsService, validationRegistry);
this.stateDir = path.join(this.elementDir, STATE_DIRECTORY);
this.triggerValidationService = validationRegistry.getTriggerValidationService();
this.validationService = validationRegistry.getValidationService();
this.serializationService = serializationService;
this.metadataService = metadataService;
}
getElementLabel() {
return 'agent';
}
/**
* Configure the element manager resolver for element-agnostic activation
* This is called by the DI container during initialization
* Follows the same pattern as Memory.configureMemoryManagerResolver
*
* @param resolver Function that takes a manager name and returns the manager instance
*/
static setElementManagerResolver(resolver) {
AgentManager.elementManagerResolver = resolver;
}
/**
* Issue #402: Set DangerZoneEnforcer resolver for DI injection.
* Called by the DI container during initialization.
*/
static setDangerZoneEnforcerResolver(resolver) {
AgentManager.dangerZoneEnforcerResolver = resolver;
}
/**
* Issue #142: Set VerificationStore resolver for DI injection.
* Called by the DI container during initialization.
*/
static setVerificationStoreResolver(resolver) {
AgentManager.verificationStoreResolver = resolver;
}
/**
* Get the element manager resolver
* @private
*/
static getElementManagerResolver() {
return AgentManager.elementManagerResolver;
}
/**
* Reset static resolvers (for test cleanup)
* Call this in afterEach hooks to prevent test isolation issues
*/
static resetResolvers() {
AgentManager.elementManagerResolver = undefined;
AgentManager.dangerZoneEnforcerResolver = undefined;
AgentManager.verificationStoreResolver = undefined;
}
/**
* Prepare directory structure for agents and state files.
*/
async initialize() {
await this.fileOperations.createDirectory(this.elementDir);
await this.fileOperations.createDirectory(this.stateDir);
logger.info('AgentManager initialized', { path: this.elementDir });
}
/**
* Create a new agent on disk.
*/
async create(name, description, content, metadata) {
try {
await this.initialize();
// Normalize goal input before validation - LLMs may pass string or object
// Strip 'content' from metadata to prevent it from overwriting the positional
// content param (which is the agent's instructions text) in the validation call.
const { content: referenceContent, ...metadataWithoutContent } = metadata ?? {};
const normalizedMetadata = {
...metadataWithoutContent
};
if (metadata?.goal !== undefined) {
normalizedMetadata.goal = this.normalizeGoalInput(metadata.goal);
}
// Use specialized validator for input validation.
// Agents support dual-field creation: behavioral instructions and optional
// reference content. Validation prefers behavioral instructions when both
// fields are present so existing instruction-first agents keep their
// current semantics while content-only agents still validate correctly.
const validationInput = {
name,
description,
...normalizedMetadata
};
const primaryText = this.getPrimaryValidationText(content, referenceContent);
validationInput.content = primaryText ?? '';
const validationResult = await this.validator.validateCreate(validationInput);
if (!validationResult.isValid) {
return {
success: false,
message: `Validation failed: ${validationResult.errors.join(', ')}`
};
}
// Log warnings if any
if (validationResult.warnings && validationResult.warnings.length > 0) {
logger.warn(`Agent creation warnings: ${validationResult.warnings.join(', ')}`);
}
// Sanitize inputs for element creation
const sanitizedName = sanitizeInput(UnicodeValidator.normalize(name).normalizedContent, 100);
const sanitizedDescription = sanitizeInput(UnicodeValidator.normalize(description).normalizedContent, 500);
// Use ContentValidator for multi-line content to preserve formatting (newlines, tabs)
// while still detecting prompt injection attacks
const contentValidation = ContentValidator.validateAndSanitize(content, { maxLength: SECURITY_LIMITS.MAX_CONTENT_LENGTH, contentContext: 'agent' });
const sanitizedInstructions = contentValidation.sanitizedContent || '';
if (!this.validateElementName(sanitizedName)) {
return {
success: false,
message: 'Invalid agent name. Use only letters, numbers, hyphens, and underscores.'
};
}
const filename = this.getFilename(sanitizedName);
const agent = new Agent({
...normalizedMetadata,
name: sanitizedName,
description: sanitizedDescription
}, this.metadataService);
agent.metadata.author = normalizedMetadata?.author ?? this.getCurrentUserForAttribution();
agent.extensions = {
...agent.extensions,
specializations: normalizedMetadata?.specializations ?? agent.extensions?.specializations ?? [],
decisionFramework: normalizedMetadata?.decisionFramework ?? agent.extensions?.decisionFramework,
riskTolerance: normalizedMetadata?.riskTolerance ?? agent.extensions?.riskTolerance,
learningEnabled: normalizedMetadata?.learningEnabled ?? agent.extensions?.learningEnabled,
};
// Promote instructions to first-class property (no longer in extensions)
agent.instructions = sanitizedInstructions;
// Also keep in extensions for backward compat during transition
agent.extensions.instructions = sanitizedInstructions;
// Set reference content if provided (v2.0 dual-field architecture)
if (typeof referenceContent === 'string' && referenceContent.trim().length > 0) {
const contentValidationResult = ContentValidator.validateAndSanitize(referenceContent, { maxLength: SECURITY_LIMITS.MAX_CONTENT_LENGTH, contentContext: 'agent' });
if (!contentValidationResult.isValid) {
return {
success: false,
message: `Validation failed: ${(contentValidationResult.detectedPatterns || ['Content validation failed']).join(', ')}`
};
}
agent.content = contentValidationResult.sanitizedContent || '';
}
// Issue #727: Validate and normalize V2 fields BEFORE assignment.
// This is the SECOND validation layer — AgentElementValidator (called above via
// this.validator.validateCreate) is the first. The validator catches camelCase
// invalid values; this method also normalizes snake_case keys (which the validator
// doesn't see) and validates them. Both layers are needed. See Issue #730.
const metadataV2 = normalizedMetadata;
if (metadataV2) {
const v2Errors = this.validateV2FieldsForCreate(metadataV2);
if (v2Errors.length > 0) {
return {
success: false,
message: `V2 field validation failed: ${v2Errors.join('; ')}`
};
}
}
// V2 FIELDS: Store V2-specific fields in agent metadata (not just extensions)
// This enables V2 agent creation via MCP-AQL create_element operation
if (metadataV2?.goal) {
agent.metadata.goal = metadataV2.goal;
}
if (metadataV2?.activates) {
agent.metadata.activates = metadataV2.activates;
}
if (metadataV2?.tools) {
agent.metadata.tools = metadataV2.tools;
}
if (metadataV2?.systemPrompt) {
agent.metadata.systemPrompt = metadataV2.systemPrompt;
}
if (metadataV2?.autonomy) {
agent.metadata.autonomy = metadataV2.autonomy;
}
// Issue #449: Persist gatekeeper policy for Gatekeeper enforcement during execution
if (metadataV2?.gatekeeper) {
agent.metadata.gatekeeper = metadataV2.gatekeeper;
}
// Issue #722: Persist resilience policy (was missing from V2 field assignments)
if (metadataV2?.resilience) {
agent.metadata.resilience = metadataV2.resilience;
}
// Issue #613: Check metadata name uniqueness (not just filename)
const existingAgents = await this.list();
const duplicate = existingAgents.find(a => a.metadata.name.toLowerCase() === sanitizedName.toLowerCase());
if (duplicate) {
return {
success: false,
message: `Agent '${sanitizedName}' already exists`
};
}
// Serialize the agent content first
const serializedContent = await this.serializeElement(agent);
const absolutePath = this.resolveAbsolutePath(filename);
// Use atomic file creation to prevent TOCTOU race conditions
// This replaces the previous check-then-write pattern with a single atomic operation
const created = await this.fileOperations.createFileExclusive(absolutePath, serializedContent, {
source: 'AgentManager.create'
});
if (!created) {
return {
success: false,
message: `Agent '${sanitizedName}' already exists`
};
}
// Cache the element after successful creation
this.cacheElement(agent, filename);
await this.storageLayer.notifySaved(filename, absolutePath);
// Note: No reload() here — cacheElement() stores the element correctly.
// See Issue #491 for why PersonaManager's reload-after-create was removed.
SecurityMonitor.logSecurityEvent({
type: 'ELEMENT_CREATED',
severity: 'LOW',
source: 'AgentManager.create',
details: `Agent '${sanitizedName}' created`,
additionalData: { agentId: agent.id }
});
return {
success: true,
message: `🤖 **${sanitizedName}** by ${agent.metadata.author || 'anonymous'}`,
element: agent
};
}
catch (error) {
logger.error('Failed to create agent', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to create agent'
};
}
}
/**
* Read an agent by name (without extension).
*
* @param name - Agent name (without extension)
* @returns Agent instance or null if not found
*/
async read(name) {
try {
const sanitizedName = sanitizeInput(name, 100);
const filename = this.getFilename(sanitizedName);
return await this.load(filename);
}
catch (error) {
if (error.code === 'ENOENT') {
// Fallback: flexible matching via list scan (#607)
return this.readFlexibly(name);
}
throw error;
}
}
/**
* Fallback for read() when direct file lookup fails.
* Searches loaded agents by metadata name using case-insensitive and slug matching.
* Logs a warning when a match is found (indicates filename/name mismatch needing cleanup).
*
* @example
* // File on disk: "legacy-poster-agent.md"
* // Metadata name: "legacy-poster"
* // Direct lookup for "legacy-poster.md" fails (ENOENT)
* // Flexible fallback matches via metadata name:
* const agent = await read("legacy-poster"); // resolves via fallback
*/
async readFlexibly(name) {
try {
const agents = await this.list();
if (agents.length === 0)
return null;
const searchLower = name.toLowerCase();
const searchSlug = this.normalizeFilename(name);
// Pass 1: exact case-insensitive match on metadata name
let match = agents.find((a) => a.metadata.name.toLowerCase() === searchLower);
// Pass 2: slug match (handles dashes, underscores, casing differences)
if (!match) {
match = agents.find((a) => {
const slug = this.normalizeFilename(a.metadata.name);
return slug === searchSlug || slug === searchLower;
});
}
if (match) {
logger.warn(`Agent "${name}" resolved via flexible matching to file with metadata name "${match.metadata.name}". ` +
`Consider renaming the file to match the expected convention (#607).`);
}
return match ?? null;
}
catch (listError) {
logger.debug(`Flexible agent lookup failed for "${name}": ${listError}`);
return null;
}
}
/**
* Update metadata/content for an existing agent.
*/
async update(name, updates, content) {
const sanitizedName = sanitizeInput(name, 100);
const agent = await this.read(sanitizedName);
if (!agent) {
logger.warn(`Agent not found for update: ${name}`);
return false;
}
// Use specialized validator for edit validation
const validationResult = await this.validator.validateEdit(agent, {
...updates,
content
});
if (!validationResult.isValid) {
logger.error(`Agent update validation failed: ${validationResult.errors.join(', ')}`);
return false;
}
// Log warnings if any
if (validationResult.warnings && validationResult.warnings.length > 0) {
logger.warn(`Agent update warnings: ${validationResult.warnings.join(', ')}`);
}
if (updates.description !== undefined) {
agent.metadata.description = sanitizeInput(UnicodeValidator.normalize(updates.description).normalizedContent, 500);
}
if (updates.specializations !== undefined) {
agent.extensions = {
...agent.extensions,
specializations: updates.specializations.map(item => sanitizeInput(item, 50))
};
}
if (updates.decisionFramework !== undefined) {
agent.extensions = {
...agent.extensions,
decisionFramework: updates.decisionFramework
};
}
if (updates.riskTolerance !== undefined) {
agent.extensions = {
...agent.extensions,
riskTolerance: updates.riskTolerance
};
}
if (updates.learningEnabled !== undefined) {
agent.extensions = {
...agent.extensions,
learningEnabled: updates.learningEnabled
};
}
if (updates.maxConcurrentGoals !== undefined) {
agent.metadata.maxConcurrentGoals = updates.maxConcurrentGoals;
}
agent.metadata.modified = new Date().toISOString();
if (content !== undefined) {
// Use ContentValidator for multi-line content to preserve formatting (newlines, tabs)
// while still detecting prompt injection attacks
const contentValidation = ContentValidator.validateAndSanitize(content, { maxLength: SECURITY_LIMITS.MAX_CONTENT_LENGTH, contentContext: 'agent' });
agent.extensions = {
...agent.extensions,
instructions: contentValidation.sanitizedContent || ''
};
}
await this.save(agent, this.getFilename(sanitizedName));
logger.info(`Agent updated: ${sanitizedName}`);
return true;
}
/**
* Validate a provided agent name.
*/
validateName(name) {
if (!name || name.trim().length === 0) {
return { valid: false, error: 'Name cannot be empty' };
}
if (name.length > 100) {
return { valid: false, error: 'Name cannot exceed 100 characters' };
}
if (!this.validateElementName(name)) {
return {
valid: false,
error: 'Name can only contain letters, numbers, hyphens, and underscores'
};
}
return { valid: true };
}
/**
* Import an agent from serialized content.
*/
async importElement(data, format = 'markdown') {
if (format === 'json') {
const parsed = this.serializationService.parseJson(data, {
source: 'AgentManager.importElement'
});
const agent = new Agent(parsed.metadata, this.metadataService);
if (parsed.state) {
agent.deserialize(JSON.stringify(parsed));
}
agent.extensions = {
...agent.extensions,
instructions: parsed.instructions || ''
};
return agent;
}
// Use SerializationService for frontmatter parsing
const result = this.serializationService.parseFrontmatter(data, {
maxYamlSize: MAX_YAML_SIZE,
validateContent: false,
source: 'AgentManager.importElement'
});
const agent = new Agent(result.data, this.metadataService);
agent.extensions = {
...agent.extensions,
instructions: result.content.trim()
};
return agent;
}
/**
* Export an agent to JSON or markdown (default).
*/
async exportElement(agent, format = 'markdown') {
if (format === 'json') {
return agent.serializeToJSON();
}
return this.serializeElement(agent);
}
/**
* Load an agent file, enforcing size and format checks.
*/
async load(filePath) {
const sanitizedInput = sanitizeInput(filePath, 255);
const relativePath = sanitizedInput.endsWith(AGENT_FILE_EXTENSION)
? sanitizedInput
: this.getFilename(sanitizeInput(sanitizedInput, 100));
try {
validatePath(relativePath, this.elementDir);
}
catch (error) {
logger.error(`Invalid agent path: ${error}`);
throw new Error(`Invalid agent path: ${error instanceof Error ? error.message : 'Invalid path'}`);
}
const fullPath = this.resolveAbsolutePath(relativePath);
try {
const content = await this.fileOperations.readFile(fullPath, { encoding: 'utf-8' });
if (content.length > MAX_FILE_SIZE) {
throw new Error(`Agent file exceeds maximum size of ${MAX_FILE_SIZE} bytes`);
}
const parsed = this.parseAgentFile(content);
const metadata = await this.parseMetadata(parsed.metadata);
const agent = this.createElement(metadata, parsed.content);
this.cacheElement(agent, relativePath);
await this.hydrateAgentState(agent, this.stripExtension(relativePath));
SecurityMonitor.logSecurityEvent({
type: 'ELEMENT_LOADED',
severity: 'LOW',
source: `${this.constructor.name}.load`,
details: `${this.getElementLabelCapitalized()} loaded: ${agent.metadata.name} v${agent.metadata.version || 'unknown'}`,
additionalData: {
agentId: agent.id,
agentName: agent.metadata.name,
version: agent.metadata.version,
author: agent.metadata.author,
}
});
return agent;
}
catch (error) {
logger.error(`Failed to load agent from ${fullPath}:`, error);
throw error;
}
}
/**
* Override BaseElementManager.save to persist state when required.
*/
async save(agent, filePath) {
const sanitizedPath = filePath.endsWith(AGENT_FILE_EXTENSION)
? sanitizeInput(filePath, 255)
: this.getFilename(sanitizeInput(filePath, 100));
await this.ensureStateDirectory();
await super.save(agent, sanitizedPath);
if (agent.needsStatePersistence()) {
const newVersion = await this.saveAgentState(this.stripExtension(sanitizedPath), agent.getState());
agent[COMMIT_PERSISTED_VERSION](newVersion); // Sync agent's internal version (Issue #123 fix)
agent.markStatePersisted();
}
}
/**
* Persist agent state to disk (public API for external callers).
*
* This method is the proper way for external code (strategies, handlers) to
* trigger state persistence. It implements the Option C pattern from Issue #123:
* stateVersion is only incremented on successful save.
*
* @param name - Agent name
* @returns Promise<boolean> - True if state was persisted, false if not needed
* @throws Error if agent not found or save fails
*/
async persistState(name) {
const agent = await this.read(name);
if (!agent) {
throw new Error(`Agent not found: ${name}`);
}
if (!agent.needsStatePersistence()) {
return false;
}
const newVersion = await this.saveAgentState(name, agent.getState());
agent[COMMIT_PERSISTED_VERSION](newVersion);
agent.markStatePersisted();
return true;
}
/**
* Override delete to remove associated state file.
*
* FIX: Uses normalizeFilename() to ensure state file deletion matches
* the normalized filename used for state file creation/loading.
*/
async delete(filePath) {
const sanitizedPath = filePath.endsWith(AGENT_FILE_EXTENSION)
? sanitizeInput(filePath, 255)
: this.getFilename(sanitizeInput(filePath, 100));
const name = this.stripExtension(sanitizedPath);
await super.delete(sanitizedPath);
// FIX: Normalize name for consistent state file deletion
const normalizedName = this.normalizeFilename(name);
const statePath = path.join(this.stateDir, `${normalizedName}${STATE_FILE_EXTENSION}`);
try {
// Back up the state file before deleting it
if (this.backupService) {
const result = await this.backupService.backupBeforeDelete(statePath, ElementType.AGENT);
if (!result.movedOriginal) {
await this.fileOperations.deleteFile(statePath, ElementType.AGENT, {
source: 'AgentManager.delete (state file)'
});
}
}
else {
await this.fileOperations.deleteFile(statePath, ElementType.AGENT, {
source: 'AgentManager.delete (state file)'
});
}
}
catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
// FIX: Use normalized name as cache key for consistent cache cleanup
this.stateCache.delete(normalizedName);
}
async exists(filePath) {
const sanitizedPath = filePath.endsWith(AGENT_FILE_EXTENSION)
? sanitizeInput(filePath, 255)
: this.getFilename(sanitizeInput(filePath, 100));
return super.exists(sanitizedPath);
}
validatePath(targetPath) {
if (targetPath.includes('..') || targetPath.includes('~')) {
return false;
}
if (targetPath.startsWith('/') || /^[A-Za-z]:/.test(targetPath)) {
return false;
}
return true;
}
getFileExtension() {
return AGENT_FILE_EXTENSION;
}
/**
* Override list to apply active status based on activeAgentNames set
*/
async list() {
const agents = await super.list();
// Apply active status to agents that are in the active set (by name)
for (const agent of agents) {
if (this.activeAgentNames.has(agent.metadata.name)) {
// Activate the agent to set status to ACTIVE
await agent.activate();
}
}
return agents;
}
/**
* Activate an agent by name or identifier
*
* Issue #24 (LOW PRIORITY): Performance optimization using findByName()
* Issue #24 (LOW PRIORITY): Consistent error messages using ElementMessages
* Issue #24 (LOW PRIORITY): Cleanup trigger for memory leak prevention
*/
async activateAgent(identifier) {
// PERFORMANCE FIX: Use findByName() instead of list()
const agent = await this.findByName(identifier);
if (!agent) {
return {
success: false,
// CONSISTENCY FIX: Use standardized error message format
message: ElementMessages.notFound(ElementType.AGENT, identifier)
};
}
// MEMORY LEAK FIX: Check if cleanup is needed before adding
this.checkAndCleanupActiveSet();
// Add to active set (by name, which is stable across reloads)
this.activeAgentNames.add(agent.metadata.name);
// Update agent status in memory
await agent.activate();
SecurityMonitor.logSecurityEvent({
type: 'AGENT_ACTIVATED',
severity: 'LOW',
source: 'AgentManager.activateAgent',
details: `Agent activated: ${agent.metadata.name} v${agent.metadata.version || 'unknown'}`,
additionalData: {
agentId: agent.id,
agentName: agent.metadata.name,
version: agent.metadata.version,
author: agent.metadata.author,
goalCount: agent.metadata.goal?.parameters?.length || 0,
specializations: agent.metadata.specializations,
}
});
logger.info(`Agent activated: ${agent.metadata.name}`);
return {
success: true,
// CONSISTENCY FIX: Use standardized success message format
message: ElementMessages.activated(ElementType.AGENT, agent.metadata.name),
agent
};
}
/**
* Deactivate an agent by name or identifier
*
* Issue #24 (LOW PRIORITY): Performance optimization using findByName()
* Issue #24 (LOW PRIORITY): Consistent error messages using ElementMessages
*/
async deactivateAgent(identifier) {
// PERFORMANCE FIX: Use findByName() instead of list()
const agent = await this.findByName(identifier);
if (!agent) {
return {
success: false,
// CONSISTENCY FIX: Use standardized error message format
message: ElementMessages.notFound(ElementType.AGENT, identifier)
};
}
// Remove from active set
this.activeAgentNames.delete(agent.metadata.name);
// Update agent status in memory
await agent.deactivate();
SecurityMonitor.logSecurityEvent({
type: 'AGENT_DEACTIVATED',
severity: 'LOW',
source: 'AgentManager.deactivateAgent',
details: `Agent deactivated: ${agent.metadata.name} v${agent.metadata.version || 'unknown'}`,
additionalData: {
agentId: agent.id,
agentName: agent.metadata.name,
version: agent.metadata.version,
author: agent.metadata.author,
}
});
logger.info(`Agent deactivated: ${agent.metadata.name}`);
return {
success: true,
// CONSISTENCY FIX: Use standardized success message format
message: ElementMessages.deactivated(ElementType.AGENT, agent.metadata.name)
};
}
/**
* Get all active agents
*/
async getActiveAgents() {
const agents = await this.list();
return agents.filter(a => this.activeAgentNames.has(a.metadata.name));
}
/**
* Execute an agent with goal parameters
*
* Returns context for LLM to drive the agentic loop.
* This method:
* 1. Loads the agent configuration
* 2. Validates and renders the goal template with parameters
* 3. Activates configured elements (element-agnostic)
* 4. Evaluates programmatic constraints and risk
* 5. Returns structured context for LLM
*
* The LLM then drives the agentic loop using this context.
*
* @since v2.0.0 - Agentic Loop Redesign
*/
async executeAgent(name, parameters,
// Thread the triggering MCP lifecycle op through validation so error messages
// can distinguish a fresh execute_agent call from a misused continue_execution.
context = {}) {
try {
// 1. Load agent by name
const agent = await this.read(name);
if (!agent) {
// FIX: Issue #275 - Throw ElementNotFoundError for consistent error handling
throw new ElementNotFoundError('Agent', name);
}
// Get metadata as v2 (may have goal config)
let metadata = agent.metadata;
// Check if this is a v2.0 agent with goal configuration
// If not, auto-convert V1 to V2 in place (Issue #587)
if (!metadata.goal || !metadata.goal.template) {
if (isV1Agent(metadata)) {
const instructions = agent.extensions?.instructions || '';
const conversionResult = convertV1ToV2(metadata, instructions);
if (conversionResult.converted) {
// Merge converted metadata onto the existing agent (in-place)
Object.assign(metadata, conversionResult.metadata);
Object.assign(agent.metadata, conversionResult.metadata);
// Log conversion warnings
if (conversionResult.warnings.length > 0) {
logger.warn(`Agent '${name}' auto-converted from V1 to V2 in place`, {
warnings: conversionResult.warnings,
});
}
// Save the upgraded agent back to its original file
const upgradedFilename = this.getFilename(sanitizeInput(name, 100));
await this.save(agent, upgradedFilename);
logger.info(`Agent '${name}' converted from V1 to V2 and saved in place`);
}
else {
throw new Error(`Agent '${name}' cannot be executed: missing goal.template and conversion failed.`);
}
}
else {
throw new Error(`Agent '${name}' is not a v2.0 agent. Missing goal.template configuration.`);
}
}
// 2. Clone parameters to prevent mutation of caller's object (Issue #118)
const clonedParameters = structuredClone(parameters);
// 2b. Security validation of template parameters (Issue #103)
this.validateParameterSecurity(clonedParameters);
// 3. Validate parameters against goal.parameters schema
this.validateParameters(metadata.goal, clonedParameters, {
agentName: name,
operationName: context.operationName ?? 'execute_agent',
});
// 4. Render goal template by replacing {parameter} placeholders
const renderedGoal = this.renderGoalTemplate(metadata.goal.template, clonedParameters);
// 4b. Detect unmatched placeholders after rendering (Issue #126)
const unmatchedPlaceholders = this.detectUnmatchedPlaceholders(renderedGoal);
if (unmatchedPlaceholders.length > 0) {
logger.warn('Unmatched template placeholders detected after rendering', {
agentName: name,
unmatched: unmatchedPlaceholders,
});
}
// 5. Create execution context BEFORE activating elements (Issue #109 - circular activation detection)
const executionContext = createExecutionContext(name);
// 5b. Static activation cycle detection (Issue #374)
if (metadata.activates?.agents?.length) {
const cyclePath = await this.detectActivationCycles(name, metadata.activates.agents);
if (cyclePath) {
const cycleStart = cyclePath.indexOf(cyclePath[cyclePath.length - 1]);
const cycle = cyclePath.slice(cycleStart);
throw new Error(AgentManager.formatCircularActivationError(cycle));
}
}
// 6. Activate elements (element-agnostic)
const activeElements = {};
const activationWarnings = [];
if (metadata.activates) {
for (const [elementType, elementNames] of Object.entries(metadata.activates)) {
if (!elementNames || elementNames.length === 0) {
continue;
}
activeElements[elementType] = [];
for (const elementName of elementNames) {
try {
const elementContent = await this.getElementContent(elementType, elementName, executionContext);
activeElements[elementType].push({
name: elementName,
content: elementContent
});
}
catch (error) {
// HIGH-1: Re-throw circular activation errors immediately (Issue #109)
if (error instanceof Error && error.message.includes('Circular agent activation detected')) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
activationWarnings.push({ elementType, elementName, error: errorMessage });
logger.warn(`Agent '${name}': failed to activate ${elementType} '${elementName}' — ${errorMessage}`);
// Continue with other elements rather than failing completely
}
}
}
}
// 7. Build the result with all context (initialize with default safety tier)
const result = {
agentName: name,
goal: renderedGoal,
activeElements,
activationWarnings: activationWarnings.length > 0 ? activationWarnings : undefined,
// Issue #126: Warn about unmatched template placeholders
templateWarnings: unmatchedPlaceholders.length > 0
? unmatchedPlaceholders.map(p => `Unmatched template placeholder: {${p}}`)
: undefined,
availableTools: metadata.tools?.allowed || [],
successCriteria: metadata.goal.successCriteria || [],
systemPrompt: metadata.systemPrompt,
safetyTier: 'advisory', // Default, will be updated below
};
// 8. Create and persist the goal for LLM tracking
// This allows record_agent_step to find and update the goal
// Create the goal using agent.addGoal() which handles validation and sanitization
const newGoal = agent.addGoal({
description: renderedGoal,
priority: 'medium',
importance: 5,
urgency: 5,
});
// Set goal status to in_progress since execution has started
newGoal.status = 'in_progress';
const execSanitizedName = sanitizeInput(name, 100);
await this.save(agent, this.getFilename(execSanitizedName));
// Store goalId in result for LLM to use with record_agent_step
result.goalId = newGoal.id;
// Fix #445: Include stateVersion so subsequent calls can do version-aware operations
const postSaveState = agent.getState();
result.stateVersion = postSaveState.stateVersion || 1;
// Call validateGoalSecurity and add warnings to result
const securityValidation = agent.validateGoalSecurity(renderedGoal);
if (securityValidation.warnings && securityValidation.warnings.length > 0) {
result.securityWarnings = securityValidation.warnings;
}
// Call evaluateConstraints and add to result
result.constraints = agent.evaluateConstraints(newGoal);
// Call assessRisk and add to result
result.riskAssessment = agent.assessRisk('execute', newGoal, {});
// Call calculatePriorityScore and add to result
result.priorityScore = agent.calculatePriorityScore(newGoal);
// 9. Determine safety tier based on risk assessment and security warnings
// (Note: executionContext already created earlier for circular detection)
const safetyTierResult = determineSafetyTier(result.riskAssessment?.score || 0, result.securityWarnings || [], renderedGoal, DEFAULT_SAFETY_CONFIG, executionContext);
// 10. Set safety tier and related fields
result.safetyTier = safetyTierResult.tier;
result.safetyTierResult = safetyTierResult;
result.executionContext = executionContext;
// 11. Add tier-specific responses
switch (safetyTierResult.tier) {
case 'confirm':
result.confirmationRequired = createConfirmationRequest('Operation requires confirmation', safetyTierResult.factors);
break;
case 'verify':
result.verificationRequired = createVerificationChallenge(safetyTierResult.factors.join('; '), 'display_code');
break;
case 'danger_zone':
result.dangerZoneBlocked = createDangerZoneOperation('agent_execution', safetyTierResult.factors.join('; '), DEFAULT_SAFETY_CONFIG.dangerZone.enabled);
// Also add verification if not blocked
if (!result.dangerZoneBlocked.blocked) {
result.verificationRequired = result.dangerZoneBlocked.verificationRequired;
}
break;
case 'advisory':
default:
// No additional action needed for advisory tier
break;
}
SecurityMonitor.logSecurityEvent({
type: 'AGENT_EXECUTED',
severity: 'LOW',
source: 'AgentManager.executeAgent',
details: `Agent executed: ${name} v${agent.metadata.version || 'unknown'} (safety: ${safetyTierResult.tier})`,
additionalData: {
agentId: agent.id,
agentName: name,
version: agent.metadata.version,
author: agent.metadata.author,
safetyTier: safetyTierResult.tier,
riskScore: safetyTierResult.riskScore,
parameterKeys: Object.keys(parameters || {}),
goalCount: metadata.goal?.parameters?.length || 0,
}
});
return result;
}
catch (error) {
logger.error(`Failed to execute agent '${name}':`, error);
throw error;
}
}
/**
* Security validation for template parameters (Issue #103).
* Checks for prototype pollution, Unicode injection, and oversized payloads.
* Must be called BEFORE template rendering.
* @private
*/
validateParameterSecurity(parameters) {
// 1. Prototype pollution check — reject dangerous keys
const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'];
for (const key of Object.keys(parameters)) {
if (FORBIDDEN_KEYS.includes(key)) {
SecurityMonitor.logSecurityEvent({
type: 'TOKEN_VALIDATION_FAILURE',
severity: 'HIGH',
source: 'AgentManager.validateParameterSecurity',
details: `Prototype pollution attempt via parameter key: '${key}'`,
});
throw new Error(`Forbidden parameter key: '${key}' (potential prototype pollution)`);
}
}
// 2. Limit parameter count to prevent resource exhaustion
const MAX_PARAMETERS = 50;
if (Object.keys(parameters).length > MAX_PARAMETERS) {
throw new Error(`Too many parameters: ${Object.keys(parameters).length} exceeds maximum of ${MAX_PARAMETERS}`);
}
// 3. Unicode normalization via InputNormalizer
const normalized = InputNormalizer.normalize(parameters, '$.parameters');
if (normalized.hasHighOrCriticalIssues) {
throw new Error(`Template parameter security validation failed: ${normalized.errors.join('; ')}`);
}
// Apply normalized values back (in-place, since we already cloned)
const normalizedData = normalized.data;
for (const [key, value] of Object.entries(normalizedData)) {
parameters[key] = value;
}
if (normalized.warnings.length > 0) {
logger.warn('Template parameter normalization warnings', {
warnings: normalized.warnings,
});
}
}
/**
* Validate parameters against goal parameter schema
* @private
*/
validateParameters(goalConfig, parameters, context = {}) {
const paramDefs = goalConfig.parameters || [];
const requiredParamNames = paramDefs
.filter(paramDef => paramDef.required)
.map(paramDef => paramDef.name);
// Check all required parameters are present
const missingRequired = requiredParamNames.filter(paramName => !(paramName in parameters));
if (missingRequired.length > 0) {
throw new Error(this.formatMissingRequiredParametersError(missingRequired, requiredParamNames, context));
}
// Type check provided parameters
for (const [key, value] of Object.entries(parameters)) {
const paramDef = paramDefs.find(p => p.name === key);
if (!paramDef) {
logger.warn(`Unknown parameter '${key}' provided to agent`);
continue;
}
// Type validation
const actualType = typeof value;
if (paramDef.type === 'string' && actualType !== 'string') {
throw new Error(`Parameter '${key}' must be a string, got ${actualType}`);
}
if (paramDef.type === 'number' && actualType !== 'number') {
throw new Error(`Parameter '${key}' must be a number, got ${actualType}`);
}
if (paramDef.type === 'boolean' && actualType !== 'boolean') {
throw new Error(`Parameter '${key}' must be a boolean, got ${actualType}`);
}
// Advisory length warning for string values (defense-in-depth, does not throw)
if (actualType === 'string' && value.length > AGENT_LIMITS.MAX_GOAL_LENGTH) {
logger.warn('Parameter string value exceeds MAX_GOAL_LENGTH (advisory)', {
paramName: key,
valueLength: value.length,
maxLength: AGENT_LIMITS.MAX_GOAL_LENGTH,
});
}
}
// Apply defaults for optional parameters not provided
for (const paramDef of paramDefs) {
if (!paramDef.required && !(paramDef.name in parameters) && paramDef.default !== undefined) {
parameters[paramDef.name] = paramDef.default;
}
}
}
/**
* Build an actionable missing-parameter error for execute/continue calls.
* @private
*/
formatMissingRequiredParametersError(missingRequired, requiredParamNames, context) {
const agentSuffix = context.agentName ? ` for agent '${context.agentName}'` : '';
let message = `Missing required parameters${agentSuffix}: ${missingRequired.join(', ')}.`;
if (requiredParamNames.length > 0) {
message += ` Required goal parameters: ${requiredParamNames.join(', ')}.`;
}
message += ' Discover the full execution contract via mcp_aql_read introspect: ' +
'{ operation: "introspect", params: { query: "operations", name: "execute_agent" } }.';
if (context.operationName === 'continue_execution') {
message += ' If you are reporting progress after execute_agent, use ' +
'mcp_aql_create record_execution_step instead. continue_execution is only ' +
'for resuming a previously paused execution and still requires the same ' +
'goal parameters as execute_agent.';
}
return message;
}
/**
* Render goal template by replacing {parameter} placeholders
* @private
*/
renderGoalTemplate(template, parameters) {
let rendered = template;
for (const [key, value] of Object.entries(parameters)) {
// Escape key to prevent regex metacharacters from causing ReDoS (Issue #103)
const escapedKey = SafeRegex.escape(key);
rendered = rendered.replace(new RegExp(`\\{${escapedKey}\\}`, 'g'), Str