277 lines
10 KiB
JavaScript
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);
|