update agents skill design
This commit is contained in:
276
src/lib/agents/registry.js
Normal file
276
src/lib/agents/registry.js
Normal file
@@ -0,0 +1,276 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user