diff --git a/tools/schema/agent.js b/tools/schema/agent.js new file mode 100644 index 00000000..345f7217 --- /dev/null +++ b/tools/schema/agent.js @@ -0,0 +1,248 @@ +// 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', 'run-workflow']; +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 = normalizeModuleOption(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) { + 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); + index += 1; + } + }) + ); +} + +/** + * 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(), + }) + .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 + .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. + */ +function buildMenuItemSchema() { + return z + .object({ + trigger: createNonEmptyString('agent.menu[].trigger'), + description: createNonEmptyString('agent.menu[].description'), + workflow: createNonEmptyString('agent.menu[].workflow').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: createNonEmptyString('agent.menu[].data').optional(), + 'run-workflow': createNonEmptyString('agent.menu[].run-workflow').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', + }); + } + }); +} + +/** + * 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 and path is well-formed, 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 parts = filePath.split('/'); + const modulesIndex = parts.indexOf('modules'); + + // If no 'modules' found in path, it's a core agent + if (modulesIndex === -1) { + return null; + } + + // If 'modules' is the last part or there's nothing after it, return null (core agent) + if (modulesIndex + 1 >= parts.length) { + return null; + } + + // Get the module name + const module = parts[modulesIndex + 1]; + + // Validate that the path is well-formed: must have 'agents' directory after module name + // Path should be: src/modules//agents/... + if (modulesIndex + 2 >= parts.length || parts[modulesIndex + 2] !== 'agents') { + // Malformed path (no /agents/ subdirectory), treat as core agent + return null; + } + + // Return module name if non-empty, otherwise null + return module && module.length > 0 ? module : null; +} + +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`, + }); +}