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

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,
};
}