// Zod schema definition for *.agent.yaml files const assert = require('node:assert'); const { z } = require('zod'); const COMMAND_TARGET_KEYS = ['workflow', 'validate-workflow', 'exec', 'action', 'tmpl', 'data']; const TRIGGER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; // Public API --------------------------------------------------------------- /** * Validate an agent YAML payload against the schema derived from its file location. * Exposed as the single public entry point, so callers do not reach into schema internals. * * @param {string} filePath Path to the agent file (used to infer module scope). * @param {unknown} agentYaml Parsed YAML content. * @returns {import('zod').SafeParseReturnType} SafeParse result. */ function validateAgentFile(filePath, agentYaml) { const expectedModule = deriveModuleFromPath(filePath); const schema = agentSchema({ module: expectedModule }); return schema.safeParse(agentYaml); } module.exports = { validateAgentFile }; // Internal helpers --------------------------------------------------------- /** * Build a Zod schema for validating a single agent definition. * The schema is generated per call so module-scoped agents can pass their expected * module slug while core agents leave it undefined. * * @param {Object} [options] * @param {string|null|undefined} [options.module] Module slug for module agents; omit or null for core agents. * @returns {import('zod').ZodSchema} Configured Zod schema instance. */ function agentSchema(options = {}) { const expectedModule = normalizeModuleOption(options.module); return ( z .object({ agent: buildAgentSchema(expectedModule), }) .strict() // Refinement: enforce trigger format and uniqueness rules after structural checks. .superRefine((value, ctx) => { const seenTriggers = new Set(); let index = 0; for (const item of value.agent.menu) { // Handle legacy format with trigger field if (item.trigger) { const triggerValue = item.trigger; if (!TRIGGER_PATTERN.test(triggerValue)) { ctx.addIssue({ code: 'custom', path: ['agent', 'menu', index, 'trigger'], message: 'agent.menu[].trigger must be kebab-case (lowercase words separated by hyphen)', }); return; } if (seenTriggers.has(triggerValue)) { ctx.addIssue({ code: 'custom', path: ['agent', 'menu', index, 'trigger'], message: `agent.menu[].trigger duplicates "${triggerValue}" within the same agent`, }); return; } seenTriggers.add(triggerValue); } // Handle multi format with triggers array (new format) else if (item.triggers && Array.isArray(item.triggers)) { for (const [triggerIndex, triggerItem] of item.triggers.entries()) { let triggerName = null; // Extract trigger name from all three formats if (triggerItem.trigger) { // Format 1: Simple flat format with trigger field triggerName = triggerItem.trigger; } else { // Format 2a or 2b: Object-key format const keys = Object.keys(triggerItem); if (keys.length === 1 && keys[0] !== 'trigger') { triggerName = keys[0]; } } if (triggerName) { if (!TRIGGER_PATTERN.test(triggerName)) { ctx.addIssue({ code: 'custom', path: ['agent', 'menu', index, 'triggers', triggerIndex], message: `agent.menu[].triggers[] must be kebab-case (lowercase words separated by hyphen) - got "${triggerName}"`, }); return; } if (seenTriggers.has(triggerName)) { ctx.addIssue({ code: 'custom', path: ['agent', 'menu', index, 'triggers', triggerIndex], message: `agent.menu[].triggers[] duplicates "${triggerName}" within the same agent`, }); return; } seenTriggers.add(triggerName); } } } index += 1; } }) // Refinement: suggest conversational_knowledge when discussion is true .superRefine((value, ctx) => { if (value.agent.discussion === true && !value.agent.conversational_knowledge) { ctx.addIssue({ code: 'custom', path: ['agent', 'conversational_knowledge'], message: 'It is recommended to include conversational_knowledge when discussion is true', }); } }) ); } /** * Assemble the full agent schema using the module expectation provided by the caller. * @param {string|null} expectedModule Trimmed module slug or null for core agents. */ function buildAgentSchema(expectedModule) { return z .object({ metadata: buildMetadataSchema(expectedModule), persona: buildPersonaSchema(), critical_actions: z.array(createNonEmptyString('agent.critical_actions[]')).optional(), menu: z.array(buildMenuItemSchema()).min(1, { message: 'agent.menu must include at least one entry' }), prompts: z.array(buildPromptSchema()).optional(), webskip: z.boolean().optional(), discussion: z.boolean().optional(), conversational_knowledge: z.array(z.object({}).passthrough()).min(1).optional(), }) .strict(); } /** * Validate metadata shape and cross-check module expectation against caller input. * @param {string|null} expectedModule Trimmed module slug or null when core agent metadata is expected. */ function buildMetadataSchema(expectedModule) { const schemaShape = { id: createNonEmptyString('agent.metadata.id'), name: createNonEmptyString('agent.metadata.name'), title: createNonEmptyString('agent.metadata.title'), icon: createNonEmptyString('agent.metadata.icon'), module: createNonEmptyString('agent.metadata.module').optional(), }; return ( z .object(schemaShape) .strict() // Refinement: guard presence and correctness of metadata.module. .superRefine((value, ctx) => { const moduleValue = typeof value.module === 'string' ? value.module.trim() : null; if (expectedModule && !moduleValue) { ctx.addIssue({ code: 'custom', path: ['module'], message: 'module-scoped agents must declare agent.metadata.module', }); } else if (!expectedModule && moduleValue) { ctx.addIssue({ code: 'custom', path: ['module'], message: 'core agents must not include agent.metadata.module', }); } else if (expectedModule && moduleValue !== expectedModule) { ctx.addIssue({ code: 'custom', path: ['module'], message: `agent.metadata.module must equal "${expectedModule}"`, }); } }) ); } function buildPersonaSchema() { return z .object({ role: createNonEmptyString('agent.persona.role'), identity: createNonEmptyString('agent.persona.identity'), communication_style: createNonEmptyString('agent.persona.communication_style'), principles: z.union([ createNonEmptyString('agent.persona.principles'), z .array(createNonEmptyString('agent.persona.principles[]')) .min(1, { message: 'agent.persona.principles must include at least one entry' }), ]), }) .strict(); } function buildPromptSchema() { return z .object({ id: createNonEmptyString('agent.prompts[].id'), content: z.string().refine((value) => value.trim().length > 0, { message: 'agent.prompts[].content must be a non-empty string', }), description: createNonEmptyString('agent.prompts[].description').optional(), }) .strict(); } /** * Schema for individual menu entries ensuring they are actionable. * Supports both legacy format and new multi format. */ function buildMenuItemSchema() { // Legacy menu item format const legacyMenuItemSchema = z .object({ trigger: createNonEmptyString('agent.menu[].trigger'), description: createNonEmptyString('agent.menu[].description'), workflow: createNonEmptyString('agent.menu[].workflow').optional(), 'workflow-install': createNonEmptyString('agent.menu[].workflow-install').optional(), 'validate-workflow': createNonEmptyString('agent.menu[].validate-workflow').optional(), exec: createNonEmptyString('agent.menu[].exec').optional(), action: createNonEmptyString('agent.menu[].action').optional(), tmpl: createNonEmptyString('agent.menu[].tmpl').optional(), data: z.string().optional(), checklist: createNonEmptyString('agent.menu[].checklist').optional(), document: createNonEmptyString('agent.menu[].document').optional(), 'ide-only': z.boolean().optional(), 'web-only': z.boolean().optional(), discussion: z.boolean().optional(), }) .strict() .superRefine((value, ctx) => { const hasCommandTarget = COMMAND_TARGET_KEYS.some((key) => { const commandValue = value[key]; return typeof commandValue === 'string' && commandValue.trim().length > 0; }); if (!hasCommandTarget) { ctx.addIssue({ code: 'custom', message: 'agent.menu[] entries must include at least one command target field', }); } }); // Multi menu item format const multiMenuItemSchema = z .object({ multi: createNonEmptyString('agent.menu[].multi'), triggers: z .array( z.union([ // Format 1: Simple flat format (has trigger field) z .object({ trigger: z.string(), input: createNonEmptyString('agent.menu[].triggers[].input'), route: createNonEmptyString('agent.menu[].triggers[].route').optional(), action: createNonEmptyString('agent.menu[].triggers[].action').optional(), data: z.string().optional(), type: z.enum(['exec', 'action', 'workflow']).optional(), }) .strict() .refine((data) => data.trigger, { message: 'Must have trigger field' }) .superRefine((value, ctx) => { // Must have either route or action (or both) if (!value.route && !value.action) { ctx.addIssue({ code: 'custom', message: 'agent.menu[].triggers[] must have either route or action (or both)', }); } }), // Format 2a: Object with array format (like bmad-builder.agent.yaml) z .object({}) .passthrough() .refine( (value) => { const keys = Object.keys(value); if (keys.length !== 1) return false; const triggerItems = value[keys[0]]; return Array.isArray(triggerItems); }, { message: 'Must be object with single key pointing to array' }, ) .superRefine((value, ctx) => { const triggerName = Object.keys(value)[0]; const triggerItems = value[triggerName]; if (!Array.isArray(triggerItems)) { ctx.addIssue({ code: 'custom', message: `Trigger "${triggerName}" must be an array of items`, }); return; } // Check required fields in the array const hasInput = triggerItems.some((item) => 'input' in item); const hasRouteOrAction = triggerItems.some((item) => 'route' in item || 'action' in item); if (!hasInput) { ctx.addIssue({ code: 'custom', message: `Trigger "${triggerName}" must have an input field`, }); } if (!hasRouteOrAction) { ctx.addIssue({ code: 'custom', message: `Trigger "${triggerName}" must have a route or action field`, }); } }), // Format 2b: Object with direct fields (like analyst.agent.yaml) z .object({}) .passthrough() .refine( (value) => { const keys = Object.keys(value); if (keys.length !== 1) return false; const triggerFields = value[keys[0]]; return !Array.isArray(triggerFields) && typeof triggerFields === 'object'; }, { message: 'Must be object with single key pointing to object' }, ) .superRefine((value, ctx) => { const triggerName = Object.keys(value)[0]; const triggerFields = value[triggerName]; // Check required fields if (!triggerFields.input || typeof triggerFields.input !== 'string') { ctx.addIssue({ code: 'custom', message: `Trigger "${triggerName}" must have an input field`, }); } if (!triggerFields.route && !triggerFields.action) { ctx.addIssue({ code: 'custom', message: `Trigger "${triggerName}" must have a route or action field`, }); } }), ]), ) .min(1, { message: 'agent.menu[].triggers must have at least one trigger' }), discussion: z.boolean().optional(), }) .strict() .superRefine((value, ctx) => { // Check for duplicate trigger names const seenTriggers = new Set(); for (const [index, triggerItem] of value.triggers.entries()) { let triggerName = null; // Extract trigger name from either format if (triggerItem.trigger) { // Format 1 triggerName = triggerItem.trigger; } else { // Format 2 const keys = Object.keys(triggerItem); if (keys.length === 1) { triggerName = keys[0]; } } if (triggerName) { if (seenTriggers.has(triggerName)) { ctx.addIssue({ code: 'custom', path: ['agent', 'menu', 'triggers', index], message: `Trigger name "${triggerName}" is duplicated`, }); } seenTriggers.add(triggerName); // Validate trigger name format if (!TRIGGER_PATTERN.test(triggerName)) { ctx.addIssue({ code: 'custom', path: ['agent', 'menu', 'triggers', index], message: `Trigger name "${triggerName}" must be kebab-case (lowercase words separated by hyphen)`, }); } } } }); return z.union([legacyMenuItemSchema, multiMenuItemSchema]); } /** * Derive the expected module slug from a file path residing under src/modules//agents/. * @param {string} filePath Absolute or relative agent path. * @returns {string|null} Module slug if identifiable, otherwise null. */ function deriveModuleFromPath(filePath) { assert(filePath, 'validateAgentFile expects filePath to be provided'); assert(typeof filePath === 'string', 'validateAgentFile expects filePath to be a string'); assert(filePath.startsWith('src/'), 'validateAgentFile expects filePath to start with "src/"'); const marker = 'src/modules/'; if (!filePath.startsWith(marker)) { return null; } const remainder = filePath.slice(marker.length); const slashIndex = remainder.indexOf('/'); return slashIndex === -1 ? null : remainder.slice(0, slashIndex); } function normalizeModuleOption(moduleOption) { if (typeof moduleOption !== 'string') { return null; } const trimmed = moduleOption.trim(); return trimmed.length > 0 ? trimmed : null; } // Primitive validators ----------------------------------------------------- function createNonEmptyString(label) { return z.string().refine((value) => value.trim().length > 0, { message: `${label} must be a non-empty string`, }); }