import { allSkills, normalizeDefinition, parseFrontmatter, sectionBullets, sectionSource, sectionText, } from '@/lib/skills/registry'; import { slugify } from '@/lib/skills/uiConfig'; import { canonicalPage } from '@/lib/skills/surfaces'; import { normalizeAgent } from './agentConfig'; /** * The agent registry. * * An agent is a Markdown file, for the same reason a skill is: frontmatter * declares what it is and what it carries, the body says how it should behave. * Nothing here executes Markdown — an agent names skill ids, and the skill * registry decides what those ids mean. * * **This is not a second parser.** `parseFrontmatter`, `normalizeDefinition` * and the three section readers are imported from the skill registry, so an * agent file and a skill file are read by exactly the same code and cannot * drift into two formats. The BOM, CRLF, blank-line and trailing-space * tolerance a skill definition gets is the tolerance an agent definition gets, * because it is the same function. * * Registration is the filesystem, as it is for skills: `import.meta.glob` * picks up everything under `src/agents/`, so a new agent is registered by * existing. * * What an agent may *do* with what it carries is `runtime.js`'s decision, and * the boundary it cannot cross is the page's — an agent contributes to the * disabled list and nothing else, so it can only ever narrow a page. */ /* `import.meta.glob` is a Vite build-time API. `tsc` models only the standard `ImportMeta`, so it reports this as a missing property — a false positive rather than a defect. Suppressed rather than cast: a cast would assert a type nothing here can verify, and this file is the one place the glob appears. */ // @ts-ignore -- Vite build-time API, absent from the standard ImportMeta type const FILES = import.meta.glob('/src/agents/**/*.md', { query: '?raw', import: 'default', eager: true }); /** * One Markdown definition → one agent. * * Shared by the files on disk and by anything authored at runtime, so an agent * written in the editor is parsed by the same code as a shipped one. */ export function parseAgent(raw, { path = 'custom', custom = false } = {}) { const { data, body } = parseFrontmatter(raw); /** * The definition's id. * * `id:` when written, and it always wins — an id is the address skills, * subagents and stored preferences refer to, and deriving over the top of * one would silently rename an agent. Falling back to a slug of the name * matches what an author means by leaving it out; falling back to the * filename is right only for a file, which is why it is last. */ const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, ''); const { agent, errors } = normalizeAgent(data, { agentId: id, /* Instructions are the body's own section, not a frontmatter string: they are prose, and prose belongs under a heading where it can be written and read as prose. */ body: sectionSource(body, 'Instructions') || '', }); return { id, name: data.name || 'Untitled agent', description: data.description || '', ...agent, /* What this definition lost on the way in. Carried on the record rather than thrown, so one bad entry costs its author that entry and a message instead of the whole file — and so `readAgentRegistry` can report it for shipped definitions too, which are otherwise never re-checked. */ errors, /* What this agent is for, as bullets, for the switcher and the detail page. Read with the same section reader skills use. */ purpose: sectionBullets(body, 'Purpose'), summary: sectionText(body, 'Purpose'), /* The definition as written. The editor edits this; everything else reads the parsed form, so there is one artefact behind all of them. */ markdown: raw, source: custom ? 'account' : 'repository', path, body, custom, }; } /** Every agent on disk, parsed once at module load. */ export const AGENTS = Object.entries(FILES) .map(([path, raw]) => parseAgent(raw, { path })) .sort((a, b) => a.name.localeCompare(b.name)); /** * The registry as actually assembled, with everything that went wrong * assembling it. * * The same four failure modes the skill registry reports, because they are the * same four failures and all of them are silent: * * - `unreadable` — a stored definition that will not parse. Dropped without * a word, an agent simply stops existing and the list looks healthy. * - `incomplete` — a definition that parses but lost a field on the way in. * - `shadowed` — an account definition replacing a shipped one of the same * id. Intended, and indistinguishable from the shipped one having broken. * - `unattached` — an agent naming a skill id no registered skill carries. * This is the one that matters most here: a skill renamed or removed * leaves every agent that named it quietly carrying one capability fewer. */ export function readAgentRegistry(customSources = [], { customSkills = [] } = {}) { const diagnostics = []; const custom = []; customSources.forEach((entry, i) => { const path = entry?.path || `custom/${i}.md`; let agent = null; try { agent = parseAgent(entry?.raw ?? entry, { path, custom: true }); } catch (error) { diagnostics.push({ level: 'error', kind: 'unreadable', path, agentId: null, message: `A stored agent could not be read and is not registered. ${error?.message || ''}`.trim(), }); return; } if (!agent?.id) { diagnostics.push({ level: 'error', kind: 'unreadable', path, agentId: null, message: 'A stored agent has no `id` and is not registered.', }); return; } custom.push(agent); }); const byId = new Map(AGENTS.map((a) => [a.id, a])); for (const agent of custom) { if (byId.has(agent.id) && !byId.get(agent.id).custom) { diagnostics.push({ level: 'warning', kind: 'shadowed', path: agent.path, agentId: agent.id, message: `\`${agent.id}\` replaces the built-in agent of the same id. The built-in definition is not registered.`, }); } byId.set(agent.id, agent); } const agents = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); /* A definition that lost part of itself on the way in. Reported for every agent, not only stored ones, so a file on disk that stops resolving after a vocabulary change is just as visible. */ for (const agent of agents) { for (const message of agent.errors || []) { diagnostics.push({ level: 'error', kind: 'incomplete', path: agent.path, agentId: agent.id, message, }); } } /* Skills and subagents that do not resolve. Both are addresses, and an address that points at nothing is the failure this catches. */ const skillIds = new Set(allSkills(customSkills).map((s) => s.id)); const agentIds = new Set(agents.map((a) => a.id)); for (const agent of agents) { for (const skillId of agent.skills) { if (skillIds.has(skillId)) continue; diagnostics.push({ level: 'error', kind: 'unattached', path: agent.path, agentId: agent.id, message: `\`${agent.id}\` names the skill \`${skillId}\`, which no registered skill provides.`, }); } for (const subId of agent.subagents) { if (agentIds.has(subId)) continue; diagnostics.push({ level: 'error', kind: 'unattached', path: agent.path, agentId: agent.id, message: `\`${agent.id}\` names the subagent \`${subId}\`, which no registered agent provides.`, }); } } return { agents, diagnostics }; } /** Every agent, shipped and stored. */ export function allAgents(customSources = [], options = {}) { return readAgentRegistry(customSources, options).agents; } /** Everything that went wrong assembling it, for the management page. */ export function agentDiagnostics(customSources = [], options = {}) { return readAgentRegistry(customSources, options).diagnostics; } /** One agent by id, or null. */ export const getAgent = (agents, id) => (agents || []).find((a) => a.id === id) || null; /** The agents an author may actually pick: published, never archived. */ export const publishedAgents = (agents = []) => agents.filter((a) => a.status === 'published'); /** * Agents matching a search, over the fields a person would search by. * * An empty query is every agent rather than none — the switcher opens with the * box empty, and an empty list would read as "there are no agents". */ export function searchAgents(agents = [], query = '') { const q = String(query || '').trim().toLowerCase(); if (!q) return agents; return agents.filter((a) => [a.name, a.description, a.trigger, ...(a.purpose || [])] .filter(Boolean) .some((field) => String(field).toLowerCase().includes(q)) ); } /** * Validates a definition before it is stored. Returns an error string or null. * * The order is the order an author would fix things in, which is what * `validateSkillSource` does and why this reads the same way. * * Deliberately *not* refused: an agent carrying no skills. Five of this * product's pages have no Owliver skills at all and answer from their own page * responder, so refusing a skill-less agent would mean inventing placeholder * skills to make those pages configurable. See the note in `agentConfig.js`. */ export function validateAgentSource(raw) { if (!String(raw ?? '').trim()) return 'Paste or upload a Markdown definition.'; let agent; try { agent = parseAgent(raw, { custom: true }); } catch (error) { /* The YAML subset reports the line it failed on, which is far more useful than "could not be parsed". */ return `That definition could not be parsed. ${error?.message || ''}`.trim(); } if (!agent.id) return 'The frontmatter needs an `id`.'; if (!/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) { return 'The `id` must be lower-case letters, numbers and dashes.'; } if (!raw.includes('name:') || agent.name === 'Untitled agent') { return 'The frontmatter needs a `name`.'; } if (!agent.pages.length) { return 'An agent needs at least one `pages:` entry, or it can never be offered anywhere.'; } if (agent.errors?.length) return agent.errors[0]; return null; } /** Page keys an agent may cover, for the editor's own guidance. */ export const agentPageKeys = (pages = []) => pages.map((p) => canonicalPage(p) || p).filter(Boolean);