Files
doormilxpress_astryx/src/lib/agents/registry.js
2026-08-20 18:18:10 +05:30

277 lines
10 KiB
JavaScript

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);