import { canonicalPage } from '@/lib/skills/surfaces'; import { slugify } from '@/lib/skills/uiConfig'; import { AGENT_ACCESS, AGENT_STATUSES, DEFAULT_AGENT_ACCESS, DEFAULT_AGENT_ICON, DEFAULT_AGENT_STATUS, DEFAULT_KNOWLEDGE_KIND, DEFAULT_PERMISSION_ROLE, DEFAULT_REASONING, KNOWLEDGE_KINDS, PERMISSION_ROLES, SUPPORTED_REASONING, isAgentIcon, reasoningFor, } from './vocabulary'; /** * An agent definition's frontmatter, checked and normalized. * * The same contract `normalizeSkillOwliver` holds, for the same reasons: * * - **Everything is optional.** A definition that declares only an id and a * name normalizes to a working agent with documented defaults. That is what * lets the five zero-skill pages have real agents without inventing skills * to fill them. * - **Nothing unknown survives.** Statuses, reasoning modes, pages, icons, * knowledge kinds and permission roles are checked against the closed * tables in `vocabulary.js`; an unrecognised value is a named error rather * than a dropped key. * - **What validates is kept.** One bad entry costs its author that entry and * a message, never the rest of the file. * * One rule is deliberately *absent*: an agent with no skills is not refused. * A skill with no capabilities genuinely cannot answer, which is why the skill * validator refuses one — but an agent with no skills still has its page's own * responder, which is how Control Center, Hired History, Talent Pool, Activity * and Profile answer today. Refusing them would force placeholder skills into * the registry to make the UI look complete, and a registry that lies about * what exists is worse than a short list. */ /** What a definition that declares nothing gets. */ export const NO_PERMISSIONS = Object.freeze({ owner: '', access: DEFAULT_AGENT_ACCESS, people: [], }); const asList = (value) => { if (Array.isArray(value)) return value; if (value === null || value === undefined || value === '') return []; return [value]; }; const trimmed = (value) => String(value ?? '').trim(); /** Deduped, order preserved — the order an author wrote is the order shown. */ function uniqueStrings(raw, { where, errors, label }) { const seen = new Set(); const out = []; asList(raw).forEach((entry, index) => { const value = trimmed(entry); if (!value) { errors.push(`${where}[${index}]: ${label} cannot be blank.`); return; } if (seen.has(value)) return; seen.add(value); out.push(value); }); return out; } /** * The pages this agent covers, as canonical surface keys. * * Through `canonicalPage`, so a definition may write an alias — `university` * for `krow-forge` — exactly as a skill may, and the two vocabularies cannot * drift apart. An unknown page is an error rather than a silently dropped * entry, because a page nobody recognises is an agent that will never appear * anywhere and give no reason why. */ function normalizePages(raw, { errors }) { const seen = new Set(); const pages = []; asList(raw).forEach((entry, index) => { const written = trimmed(entry); if (!written) { errors.push(`pages[${index}]: a page cannot be blank.`); return; } const canonical = canonicalPage(written); if (!canonical) { errors.push(`pages[${index}]: \`${written}\` is not a page this product has.`); return; } if (seen.has(canonical)) return; seen.add(canonical); pages.push(canonical); }); return pages; } /** * One conversation starter, in either the plain-string or the mapping form. * * The same two shapes `normalizeSuggestion` accepts for skills, so an author * who has written one has already written the other. */ function normalizeStarter(raw, { errors, index }) { const where = `starters[${index}]`; if (typeof raw === 'string' || typeof raw === 'number') { const label = trimmed(raw); if (!label) { errors.push(`${where}: a starter needs text.`); return null; } return { label, prompt: label }; } if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { errors.push(`${where}: a starter must be a line of text, or a mapping of options.`); return null; } const label = trimmed(raw.label ?? raw.prompt); if (!label) { errors.push(`${where}: a starter needs a \`label\`.`); return null; } /* A starter with no prompt of its own asks what it says. */ return { label, prompt: trimmed(raw.prompt) || label }; } /** * One knowledge entry. * * Modelled as a document with an id and a body even though it is one authored * note today, because that is the shape a retrieval layer reads — see * `knowledge.js`. Getting the shape right now is what makes a later move to a * real store a change of transport rather than a change of format. */ function normalizeKnowledge(raw, { errors, index }) { const where = `knowledge[${index}]`; if (typeof raw === 'string' || typeof raw === 'number') { const body = trimmed(raw); if (!body) { errors.push(`${where}: a knowledge entry needs text.`); return null; } return { id: slugify(body.slice(0, 40)) || `k${index + 1}`, label: body.slice(0, 60), kind: DEFAULT_KNOWLEDGE_KIND, body, url: '' }; } if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { errors.push(`${where}: a knowledge entry must be a line of text, or a mapping of options.`); return null; } const label = trimmed(raw.label); const body = trimmed(raw.body); const url = trimmed(raw.url); if (!label && !body) { errors.push(`${where}: a knowledge entry needs a \`label\` or a \`body\`.`); return null; } const kind = trimmed(raw.kind) || DEFAULT_KNOWLEDGE_KIND; if (!KNOWLEDGE_KINDS.includes(kind)) { errors.push(`${where}: \`${kind}\` is not a knowledge kind. Use one of ${KNOWLEDGE_KINDS.join(', ')}.`); return null; } if (kind === 'link' && !url) { errors.push(`${where}: a \`link\` needs a \`url\`.`); return null; } return { id: trimmed(raw.id) || slugify(label) || `k${index + 1}`, label: label || body.slice(0, 60), kind, body, url, }; } /** Who owns the agent, who may reach it, and what they may do. */ function normalizePermissions(raw, { errors }) { if (raw === null || raw === undefined) return { ...NO_PERMISSIONS }; if (typeof raw !== 'object' || Array.isArray(raw)) { errors.push('permissions: must be a mapping of `owner`, `access` and `people`.'); return { ...NO_PERMISSIONS }; } const access = trimmed(raw.access) || DEFAULT_AGENT_ACCESS; if (!AGENT_ACCESS.includes(access)) { errors.push(`permissions.access: \`${access}\` is not an access mode. Use one of ${AGENT_ACCESS.join(', ')}.`); } const people = []; asList(raw.people).forEach((entry, index) => { const where = `permissions.people[${index}]`; if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { errors.push(`${where}: must be a mapping of \`user\` and \`role\`.`); return; } const user = trimmed(entry.user); if (!user) { errors.push(`${where}: needs a \`user\`.`); return; } const role = trimmed(entry.role) || DEFAULT_PERMISSION_ROLE; if (!PERMISSION_ROLES.includes(role)) { errors.push(`${where}: \`${role}\` is not a role. Use one of ${PERMISSION_ROLES.join(', ')}.`); return; } people.push({ user, role }); }); return { owner: trimmed(raw.owner), access: AGENT_ACCESS.includes(access) ? access : DEFAULT_AGENT_ACCESS, people, }; } /** * One agent's frontmatter → `{ agent, errors }`. * * `agentId` is the id already derived by the caller, used to reject an agent * that names itself as its own subagent — a cycle the runtime would otherwise * have to defend against on every turn. */ export function normalizeAgent(raw, { agentId = '', body = '' } = {}) { const errors = []; const data = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; if (raw && (typeof raw !== 'object' || Array.isArray(raw))) { errors.push('An agent definition must be a mapping of options.'); } const status = trimmed(data.status) || DEFAULT_AGENT_STATUS; if (!AGENT_STATUSES.includes(status)) { errors.push(`status: \`${status}\` is not a status. Use one of ${AGENT_STATUSES.join(', ')}.`); } const reasoning = trimmed(data.reasoning) || DEFAULT_REASONING; if (!reasoningFor(reasoning)) { errors.push(`reasoning: \`${reasoning}\` is not a reasoning mode. Use one of ${SUPPORTED_REASONING.join(', ')}.`); } const icon = trimmed(data.icon) || DEFAULT_AGENT_ICON; if (!isAgentIcon(icon)) { errors.push(`icon: \`${icon}\` is not an icon this product has.`); } /* A version is an integer that only ever goes up. Anything else is an authoring slip, and reading it as 1 is kinder than refusing the file — but it is still reported, because a definition that thinks it is v3 and registers as v1 will publish over something. */ let version = 1; if (data.version !== undefined && data.version !== null && data.version !== '') { const parsed = Number(data.version); if (!Number.isInteger(parsed) || parsed < 1) { errors.push(`version: \`${data.version}\` is not a whole number of 1 or more.`); } else { version = parsed; } } const subagents = uniqueStrings(data.subagents, { where: 'subagents', errors, label: 'a subagent id', }).filter((id) => { if (agentId && id === agentId) { errors.push('subagents: an agent cannot be its own subagent.'); return false; } return true; }); const starters = asList(data.starters) .map((entry, index) => normalizeStarter(entry, { errors, index })) .filter(Boolean); const knowledge = asList(data.knowledge) .map((entry, index) => normalizeKnowledge(entry, { errors, index })) .filter(Boolean); return { agent: { status: AGENT_STATUSES.includes(status) ? status : DEFAULT_AGENT_STATUS, version, /* When to reach for this agent, in the author's words. Shown in the switcher and carried to the runtime; never matched on, so it can be prose rather than keywords. */ trigger: trimmed(data.trigger), reasoning: reasoningFor(reasoning) ? reasoning : DEFAULT_REASONING, icon: isAgentIcon(icon) ? icon : DEFAULT_AGENT_ICON, webSearch: data.webSearch === true || data.web_search === true, pages: normalizePages(data.pages, { errors }), skills: uniqueStrings(data.skills, { where: 'skills', errors, label: 'a skill id' }), subagents, knowledge, starters, permissions: normalizePermissions(data.permissions, { errors }), /* The body's own sections, read by the caller and passed through here so one record carries everything a definition said. */ instructions: trimmed(body), }, errors, }; }