314 lines
11 KiB
JavaScript
314 lines
11 KiB
JavaScript
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,
|
|
};
|
|
}
|