update agents skill design

This commit is contained in:
2026-08-20 18:18:10 +05:30
parent 161b237695
commit b2e6868824
75 changed files with 14586 additions and 98 deletions

View File

@@ -0,0 +1,313 @@
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,
};
}

View File

@@ -0,0 +1,209 @@
import { REMOVE, patchFrontmatter } from '@/lib/skills/skillFields';
import { canonicalPage } from '@/lib/skills/surfaces';
import { parseAgent } from './registry';
/**
* An agent definition ⇄ the fields an editor shows.
*
* Two directions, one field set. `agentFieldsFromSource` reads a definition
* into the form; `agentPatch` writes the form back as a frontmatter patch.
* Both name the same keys, so a field cannot exist in one direction only —
* which is the bug that made the skill editors' upload path silently edit
* nothing.
*
* The patch is consumed by the **existing** `patchFrontmatter`. There is no
* second writer: `writeBlock` already emits nested block maps and
* `- key: value` sequences to any depth, and `yaml.js` reads them back, so
* `permissions.people` and `knowledge` round-trip with no change to either.
* That claim is asserted in `skill-check.mjs` rather than assumed.
*/
/** What a fresh editor screen holds. */
export const EMPTY_AGENT_FIELDS = Object.freeze({
id: '',
name: '',
description: '',
icon: '',
trigger: '',
status: 'draft',
version: 1,
reasoning: 'balanced',
webSearch: false,
pages: [],
skills: [],
subagents: [],
knowledge: [],
starters: [],
instructions: '',
permissions: { owner: '', access: 'all', people: [] },
});
/**
* A definition, as fields.
*
* Reads through `parseAgent`, so the form is filled from exactly what the
* runtime will see rather than from a second reading of the same text.
*/
export function agentFieldsFromSource(source) {
let agent;
try {
agent = parseAgent(source, { custom: true });
} catch {
/* A half-typed definition fills nothing rather than emptying fields that
are already filled in. The caller decides what to do about that. */
return { ...EMPTY_AGENT_FIELDS };
}
return {
id: agent.id || '',
name: agent.name === 'Untitled agent' ? '' : agent.name,
description: agent.description || '',
icon: agent.icon || '',
trigger: agent.trigger || '',
status: agent.status,
version: agent.version,
reasoning: agent.reasoning,
webSearch: agent.webSearch,
pages: [...agent.pages],
skills: [...agent.skills],
subagents: [...agent.subagents],
knowledge: agent.knowledge.map((k) => ({ ...k })),
starters: agent.starters.map((s) => ({ ...s })),
instructions: agent.instructions || '',
permissions: {
owner: agent.permissions.owner,
access: agent.permissions.access,
people: agent.permissions.people.map((p) => ({ ...p })),
},
};
}
/** An empty list clears the key rather than writing `key:` with nothing under it. */
const listOrRemove = (list) => (list && list.length ? list : REMOVE);
/**
* The frontmatter a set of fields means.
*
* `undefined` leaves a key exactly as the author wrote it — so an editor that
* only knows about four fields cannot erase the other ten, and a definition
* hand-written with comments and key order survives being saved from the form.
*/
export function agentPatch(fields = {}) {
const patch = {};
const scalar = (key, value) => {
if (value === undefined) return;
patch[key] = value === '' ? REMOVE : value;
};
scalar('id', fields.id);
scalar('name', fields.name);
scalar('description', fields.description);
scalar('icon', fields.icon);
scalar('trigger', fields.trigger);
scalar('status', fields.status);
if (fields.version !== undefined) patch.version = fields.version;
scalar('reasoning', fields.reasoning);
if (fields.webSearch !== undefined) patch.webSearch = Boolean(fields.webSearch);
/* Pages are canonicalized on the way out, so a definition saved from the
form names surfaces the way the vocabulary does — an author may still
write an alias by hand, and it will still read. */
if (fields.pages !== undefined) {
patch.pages = listOrRemove(
(fields.pages || []).map((p) => canonicalPage(p) || p).filter(Boolean)
);
}
if (fields.skills !== undefined) patch.skills = listOrRemove(fields.skills);
if (fields.subagents !== undefined) patch.subagents = listOrRemove(fields.subagents);
if (fields.starters !== undefined) {
patch.starters = listOrRemove(
(fields.starters || []).map((s) =>
/* A starter that asks what it says is one line, not two. */
(s.prompt && s.prompt !== s.label ? { label: s.label, prompt: s.prompt } : { label: s.label })
)
);
}
if (fields.knowledge !== undefined) {
patch.knowledge = listOrRemove(
(fields.knowledge || []).map((k) => ({
id: k.id || undefined,
label: k.label || undefined,
kind: k.kind || undefined,
body: k.body || undefined,
url: k.url || undefined,
}))
);
}
if (fields.permissions !== undefined) {
const { owner, access, people } = fields.permissions || {};
patch.permissions = {
owner: owner || undefined,
access: access || undefined,
people: people && people.length
? people.map((p) => ({ user: p.user, role: p.role }))
: undefined,
};
}
return patch;
}
/**
* Replaces the prose under a `## Heading`, keeping everything around it.
*
* Instructions are the one configurable thing that does **not** live in
* frontmatter: they are prose, and prose belongs under a heading where it can
* be written and read as prose. That means `agentPatch` alone cannot save them —
* it writes frontmatter, and an edit to instructions would be silently dropped.
*
* Matches the same section the parser reads (`sectionSource`), so what is
* written here is exactly what is read back. A definition with no such heading
* gains one rather than losing the edit.
*/
function writeSection(body, heading, text) {
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(^|\\n)##\\s+${escaped}\\s*\\n[\\s\\S]*?(?=\\n##\\s|$)`, 'i');
const content = String(text ?? '').trim();
if (pattern.test(body)) {
/* An emptied section is removed rather than left as a bare heading with
nothing under it, which reads as an author who meant to write something. */
return content
? body.replace(pattern, `$1## ${heading}\n\n${content}\n`)
: body.replace(pattern, '$1');
}
if (!content) return body;
/* Appended, because a definition that never had this section has no place
the author intended it to go. */
return `${body.trimEnd()}\n\n## ${heading}\n\n${content}\n`;
}
/**
* Fields, applied to a definition.
*
* The one place the two halves meet: frontmatter through the existing
* `patchFrontmatter`, and the prose sections through `writeSection`. A caller
* therefore never has to remember which half a given field lives in — which is
* exactly the mistake that made instructions silently unsaveable.
*/
export function applyAgentFields(source, fields = {}) {
const patched = patchFrontmatter(source, agentPatch(fields));
if (fields.instructions === undefined) return patched;
/* Split on the closing fence so the body can be rewritten without touching
the frontmatter that was just written. */
const match = /^---[ \t]*\n[\s\S]*?\n---[ \t]*\n?/.exec(patched);
if (!match) return patched;
const head = patched.slice(0, match[0].length);
const body = patched.slice(match[0].length);
return head + writeSection(body, 'Instructions', fields.instructions);
}

View File

@@ -0,0 +1,83 @@
import { patchFrontmatter } from '@/lib/skills/skillFields';
import { agentPatch } from './agentFields';
import { parseAgent } from './registry';
import { slugify } from '@/lib/skills/uiConfig';
/**
* Publishing, archiving and duplicating an agent.
*
* Pure functions over Markdown: each returns a new definition, and the caller
* decides whether to store it. Keeping them out of the components means the
* list and the detail page cannot implement "publish" two slightly different
* ways — the failure that produces an agent published from one screen and not
* the other.
*
* The one rule worth stating plainly: **publishing never silently overwrites a
* published version.** A draft taken from v1 and published while someone else
* moved the definition to v2 is a conflict, not a save. Returning it as a
* conflict lets the screen say so; overwriting would be indistinguishable from
* the other change never having been made.
*/
/**
* The definition, published.
*
* Returns `{ source }` when the publish is safe, or `{ conflict }` when the
* stored definition has moved past the version this draft was taken from.
*/
/** @param {string} source @param {any} [options] */
export function publishAgent(source, { publishedVersion = 0 } = {}) {
const agent = parseAgent(source, { custom: true });
if (publishedVersion && agent.version < publishedVersion) {
return {
conflict: {
draftVersion: agent.version,
publishedVersion,
message: `This draft was taken from v${agent.version}, but v${publishedVersion} is published. `
+ 'Publishing would discard that change.',
},
};
}
/* A first publish keeps its version; republishing an already-published
definition moves it on, so "what is live" is always a specific version. */
const next = agent.status === 'published' ? agent.version + 1 : Math.max(agent.version, 1);
return { source: patchFrontmatter(source, agentPatch({ status: 'published', version: next })) };
}
/** The definition, taken out of service. Its content is untouched. */
export const archiveAgent = (source) =>
patchFrontmatter(source, agentPatch({ status: 'archived' }));
/** The definition, put back into service as a draft rather than live. */
export const restoreAgent = (source) =>
patchFrontmatter(source, agentPatch({ status: 'draft' }));
/**
* A copy, under a new id.
*
* Always a draft at v1, whatever the original was: a duplicate of a published
* agent is a starting point, and inheriting `published` would put an unreviewed
* copy into the switcher the moment it was made.
*/
/** @param {string} source @param {any} [options] */
export function duplicateAgent(source, { name, existingIds = [] } = {}) {
const agent = parseAgent(source, { custom: true });
const copyName = name || `${agent.name} copy`;
/* An id nobody is using. Suffixed rather than randomised so the address stays
something a person can read and type. */
const base = slugify(copyName) || `${agent.id}-copy`;
let id = base;
let n = 2;
while (existingIds.includes(id)) {
id = `${base}-${n}`;
n += 1;
}
return patchFrontmatter(source, agentPatch({
id, name: copyName, status: 'draft', version: 1,
}));
}

114
src/lib/agents/context.js Normal file
View File

@@ -0,0 +1,114 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { pageKeyForContext, routeForPageKey } from '@/lib/skills/registry';
import { canonicalPage, surfaceFor } from '@/lib/skills/surfaces';
/**
* The context envelope — what the runtime is told about where the reader is.
*
* Composed from what already exists rather than replacing it. `PageContext.jsx`
* is untouched: it remains the one channel a page publishes its selection
* through, and this reads that channel and adds the address of the page around
* it. Two reasons that separation is worth keeping:
*
* - **The page channel is the boundary.** A page publishes records it already
* has, and nothing here reaches back into the page. Rebuilding it as an
* "agent context" would make the agent the thing that decides what is
* visible, which is exactly the inversion this design refuses.
* - **A page that publishes nothing still resolves.** Every field below has a
* defined empty value, so a page that has adopted none of the optional
* publishing conventions produces a valid envelope describing where the
* reader is and nothing more — identical behaviour to before this existed.
*
* @typedef {Object} OwliverContext
* @property {string} page The page's own label ("Positions")
* @property {string|null} pageKey Its surface key ("positions")
* @property {string|null} route The real Admin path
* @property {string|null} contextId The assistant context id
* @property {string|null} period The period the page is filtered to
* @property {Object} filters Whatever the page published as filters
* @property {any[]} selectedItems Records the page published as selected
* @property {string[]} visibleWidgets Section ids the page published as on screen
* @property {Object} metrics Figures the page has already computed
* @property {Object|null} position From the existing PageContext channel
* @property {Object|null} candidate From the existing PageContext channel
* @property {string[]} writableSources Sources this page accepts writes for
*/
/** What a page that publishes nothing produces. */
const EMPTY = Object.freeze({
filters: Object.freeze({}),
selectedItems: Object.freeze([]),
visibleWidgets: Object.freeze([]),
metrics: Object.freeze({}),
});
const asObject = (value) =>
value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
/**
* Builds the envelope for the page the reader is on.
*
* `route` is resolved through the existing placement table rather than being
* assembled from strings, so nothing here can name an address the product does
* not have.
*/
export function buildOwliverContext({
context = null,
pathname = '',
pageContext = {},
writableSources = [],
} = {}) {
const contextId = context?.id ?? null;
const pageKey = contextId ? pageKeyForContext(contextId) : null;
/* The path the reader is actually on wins; the placement table answers when
no path was handed over (a test, a background read). */
const declaredRoute = pageKey ? routeForPageKey(pageKey) : null;
const route = PLACEMENT_ROUTES[pathname] ? pathname : declaredRoute;
const published = asObject(pageContext);
return {
page: context?.page ?? (pageKey ? surfaceFor(pageKey)?.label ?? pageKey : ''),
pageKey: pageKey ? canonicalPage(pageKey) || pageKey : null,
route: route ?? null,
contextId,
/* The optional publishing convention. A page adopts as much of it as it
has, and a page that adopts none of it is not broken — it simply has no
filters, no selection and no widgets to declare. */
period: published.period ?? null,
filters: asObject(published.filters) === published.filters
? published.filters
: EMPTY.filters,
selectedItems: asArray(published.selectedItems),
visibleWidgets: asArray(published.visibleWidgets),
metrics: asObject(published.metrics),
/* The two records the existing channel already carries, named explicitly
because every source that needs one names one of them. */
position: published.position ?? null,
candidate: published.candidate ?? null,
/* What this page will accept a write for. A tool cannot write anywhere the
page has not offered — see `tools.js`. */
writableSources: asArray(writableSources),
};
}
/**
* The envelope, reduced to what is worth storing beside a conversation.
*
* Deliberately not the whole thing. `selectedItems` and `metrics` are records
* and computed figures; writing them into storage on every turn would put the
* dataset in localStorage a message at a time. What a reviewer needs later is
* *where* the question was asked, not a copy of what was on screen.
*/
export const storableContext = (envelope) => ({
page: envelope?.page ?? '',
pageKey: envelope?.pageKey ?? null,
route: envelope?.route ?? null,
period: envelope?.period ?? null,
});

View File

@@ -0,0 +1,153 @@
/**
* What the stored conversations add up to.
*
* Pure selectors over the archive `history.js` returns: no reading, no React,
* no knowledge of who is asking. Both the Insights view and the Conversation
* Reviews view read through here, so a figure quoted in one and a list shown in
* the other cannot describe different things.
*
* **Nothing is invented.** Every counter is derived from records that exist,
* and a workspace with no conversations returns `empty: true` rather than a row
* of zeros. A zero and an absence look identical on a dashboard and mean
* completely different things — one says the agent was asked and did nothing,
* the other says it has not been asked. The empty flag is what lets the view
* say which.
*/
const DAY = 24 * 60 * 60 * 1000;
const at = (record) => new Date(record?.updatedAt || 0).getTime();
/** Records for one agent, or all of them when no agent is named. */
export function conversationsForAgent(records = [], agentId = null) {
if (!agentId) return [...records];
return records.filter((record) => record.agentId === agentId);
}
/** Conversations nobody has rated yet — the queue a reviewer works through. */
export const unratedConversations = (records = [], agentId = null) =>
conversationsForAgent(records, agentId).filter((record) => !record.feedback);
/** Counts by a key each record contributes many of. */
function tally(records, pick) {
const counts = new Map();
for (const record of records) {
for (const value of pick(record) || []) {
if (!value) continue;
counts.set(value, (counts.get(value) || 0) + 1);
}
}
return [...counts.entries()]
.map(([id, count]) => ({ id, count }))
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
}
/** Counts by a key each record contributes one of. */
function tallyOne(records, pick) {
const counts = new Map();
for (const record of records) {
const value = pick(record);
if (!value) continue;
counts.set(value, (counts.get(value) || 0) + 1);
}
return [...counts.entries()]
.map(([id, count]) => ({ id, count }))
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
}
/**
* The figures an Insights view reports.
*
* `since` windows the archive; `agentId` narrows it to one agent. Both are
* optional, and neither invents a record that is not there.
*/
export function conversationStats(records = [], { agentId = null, since = null } = {}) {
const scoped = conversationsForAgent(records, agentId)
.filter((record) => (since ? at(record) >= new Date(since).getTime() : true));
if (!scoped.length) {
return {
empty: true,
total: 0,
turns: 0,
averageTurns: 0,
pages: 0,
days: 0,
activeDays: 0,
feedback: { up: 0, down: 0, unrated: 0, score: null },
byAgent: [],
byPage: [],
bySkill: [],
byTool: [],
byDay: [],
};
}
const turns = scoped.reduce((sum, record) => sum + (record.turns || 0), 0);
const up = scoped.filter((r) => r.feedback?.rating === 'up').length;
const down = scoped.filter((r) => r.feedback?.rating === 'down').length;
const rated = up + down;
/* Conversations per day, oldest first, over the days that actually have
one. Padding out empty days would draw a chart mostly made of zeros and
make a quiet week look like an outage. */
const perDay = new Map();
for (const record of scoped) {
const day = new Date(at(record));
day.setHours(0, 0, 0, 0);
const key = day.toISOString().slice(0, 10);
perDay.set(key, (perDay.get(key) || 0) + 1);
}
const byDay = [...perDay.entries()]
.map(([day, count]) => ({ id: day, day, count }))
.sort((a, b) => a.day.localeCompare(b.day));
const span = scoped.length
? Math.max(1, Math.round((Math.max(...scoped.map(at)) - Math.min(...scoped.map(at))) / DAY) + 1)
: 0;
return {
empty: false,
total: scoped.length,
turns,
averageTurns: Math.round((turns / scoped.length) * 10) / 10,
pages: new Set(scoped.map((r) => r.contextId).filter(Boolean)).size,
/* Days the archive spans, and days anything was actually asked. Reporting
only the first would make a busy afternoon look like a busy fortnight. */
days: span,
activeDays: byDay.length,
feedback: {
up,
down,
unrated: scoped.length - rated,
/* Null rather than 0 when nothing is rated: a score of zero reads as
unanimous disapproval. */
score: rated ? Math.round((up / rated) * 100) : null,
},
byAgent: tallyOne(scoped, (r) => r.agentId),
byPage: tallyOne(scoped, (r) => r.pageContext?.page || r.page),
bySkill: tally(scoped, (r) => r.skillsUsed),
byTool: tally(scoped, (r) => r.toolsUsed),
byDay,
};
}
/**
* One conversation, reduced to what a review list shows.
*
* The thread itself is deliberately not included: a list renders forty of these
* and only one is ever opened.
*/
export const reviewRow = (record) => ({
id: record.id,
title: record.title,
agentId: record.agentId ?? null,
page: record.pageContext?.page || record.page || '',
route: record.pageContext?.route ?? null,
turns: record.turns || 0,
skillsUsed: record.skillsUsed || [],
toolsUsed: record.toolsUsed || [],
feedback: record.feedback ?? null,
updatedAt: record.updatedAt,
});

View File

@@ -0,0 +1,114 @@
import { parseAgent } from './registry';
import { agentPatch } from './agentFields';
import { patchFrontmatter } from '@/lib/skills/skillFields';
/**
* Account-authored agents, as stored.
*
* An agent is its Markdown source and nothing else — the same artefact a file
* in `src/agents/` is, read back by the same parser. These helpers exist so
* every writer produces that one shape; two writers would be a second agent
* system by accident.
*
* Stored in `preferences.customAgents`, mirroring `customSkills`, which means
* an agent authored here can be copied into `src/agents/` later with no
* conversion — and that editing a shipped agent is the same act as editing a
* shipped skill: an account definition of the same id, reported as `shadowed`.
*/
/** The starting definition offered to an author. */
export function agentTemplate({
id = '', name = '', description = '', pages = [], skills = [], icon = '', trigger = '',
} = {}) {
const fallback = {
id: 'my-agent',
name: 'My Agent',
description: 'What this agent is for.',
};
const label = name || fallback.name;
/* Identity is written here; everything else is written by `agentPatch`, the
same writer the editor uses on an existing file. A template that composed
its own YAML would be a second field set, and the fields one knew about
would not be the fields the other did. */
const skeleton = `---
id: ${id || fallback.id}
name: ${name || fallback.name}
description: ${description || fallback.description}
---
# ${label}
## Instructions
Describe how this agent should answer: what it is responsible for, what it
should say when it cannot help, and how it should use the skills it carries.
## Purpose
- Describe one thing this agent is for.
- Add more as needed.
`;
return patchFrontmatter(
skeleton,
agentPatch({
id: id || fallback.id,
name: label,
description: description || fallback.description,
/* A fresh agent is a draft. Creating one must never publish it. */
status: 'draft',
version: 1,
icon,
trigger,
pages,
skills,
})
);
}
/** Parses a stored entry, tolerating the bare-string form. */
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
/**
* The stored list with `source` added or replaced.
*
* Matched by id, so editing an agent overwrites its own entry rather than
* adding a near-duplicate beside it.
*/
export function upsertCustomAgent(existing = [], source) {
const agent = parseAgent(source, { custom: true });
const rest = (existing || []).filter((entry) => {
try {
return parseAgent(sourceOf(entry), { custom: true }).id !== agent.id;
} catch {
/* An unparseable entry cannot be the one being edited, and dropping it
here would delete a definition its author may still want to fix. */
return true;
}
});
return { agent, next: [...rest, { path: `custom/${agent.id}.md`, raw: source }] };
}
/** The stored list without the agent of this id. */
export function removeCustomAgent(existing = [], id) {
return (existing || []).filter((entry) => {
try {
return parseAgent(sourceOf(entry), { custom: true }).id !== id;
} catch {
return true;
}
});
}
/** The stored Markdown for one agent, or null if the account has none. */
export function customAgentSource(existing = [], id) {
for (const entry of existing || []) {
try {
if (parseAgent(sourceOf(entry), { custom: true }).id === id) return sourceOf(entry);
} catch {
/* Unparseable entries are not the one being asked for. */
}
}
return null;
}

157
src/lib/agents/knowledge.js Normal file
View File

@@ -0,0 +1,157 @@
import { agentCovers } from './runtime';
/**
* The knowledge seam.
*
* Boundary 5 of the architecture, established now and deliberately minimal.
* There is no vector store, no embedding model and no document corpus in this
* phase, and none is faked to make retrieval look implemented.
*
* What exists is real: an agent's authored `knowledge:` entries, chunked and
* matched on terms. Somebody wrote those entries, an answer that quotes one
* says where it came from, and an agent with none returns nothing rather than a
* plausible paragraph. That is a small capability honestly delivered, not a
* stub pretending to be retrieval.
*
* **The page boundary applies here exactly as it does to skills.** An agent
* that does not cover the current page retrieves nothing — otherwise knowledge
* would be the one door through which selecting an agent could reach material
* the page was not offering, which is the failure the whole design exists to
* prevent. Knowledge is scoped by the same rule as data and tools.
*
* The shape is what makes this replaceable rather than throwaway: passages come
* back as `{documentId, chunkId, text, score}`, which is what a retrieval layer
* returns. Moving to Postgres and pgvector later means reimplementing
* `retrieveKnowledge` behind the same signature — a change of transport, not a
* change of format, and the same discipline `base44Client.js` already applies
* to the data layer.
*/
/** Words too common to discriminate between one passage and another. */
const STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be', 'been',
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'as', 'that', 'this',
'it', 'its', 'we', 'our', 'us', 'you', 'your', 'i', 'me', 'my', 'what', 'which',
'who', 'when', 'where', 'how', 'why', 'do', 'does', 'did', 'can', 'could',
'should', 'would', 'will', 'about', 'says', 'say',
]);
const terms = (text) =>
String(text || '')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((word) => word.length > 2 && !STOP_WORDS.has(word));
/**
* One knowledge entry, split into passages.
*
* By sentence, because a knowledge entry is prose and a sentence is the
* smallest piece of it that still means something on its own. A fixed-width
* chunker would cut mid-clause and quote half a rule, which is worse than not
* answering.
*/
function chunk(entry) {
const body = String(entry.body || '').trim();
if (!body) return [];
const sentences = body
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter(Boolean);
return (sentences.length ? sentences : [body]).map((text, i) => ({
documentId: entry.id,
chunkId: `${entry.id}#${i}`,
label: entry.label,
kind: entry.kind,
url: entry.url || '',
text,
}));
}
/**
* Passages from this agent's own knowledge that bear on the question.
*
* Returns `{ passages, source, available, note }`. `available: false` means
* there is nothing to search — no agent, no entries, or an agent that does not
* cover this page — and the note says which. A caller must render that rather
* than treating an empty result as "the documents say nothing".
*
* @param {Object} options
* @param {Object|null} options.agent The active agent.
* @param {string|null} options.contextId The page's assistant context.
* @param {string} options.question What was asked.
* @param {number} options.limit Most passages to return.
*/
/** @param {any} [options] */
export function retrieveKnowledge({
agent = null, contextId = null, question = '', limit = 3,
} = {}) {
const empty = (note) => ({
passages: [], source: 'declared', available: false, note,
});
if (!agent) return empty('No agent is active, so there is no knowledge to search.');
/* The page boundary. An agent constrained here contributes no knowledge, for
the same reason it contributes no skills. */
if (contextId && !agentCovers(agent, contextId)) {
return empty(`${agent.name} does not cover this page, so its knowledge is not in reach here.`);
}
const entries = (agent.knowledge || []).filter((entry) => entry.body || entry.url);
if (!entries.length) {
return empty(`${agent.name} has no knowledge attached.`);
}
const wanted = terms(question);
if (!wanted.length) {
return {
passages: [], source: 'declared', available: true,
note: 'Ask about something specific and I will check this agent\'s knowledge.',
};
}
const passages = entries
.flatMap(chunk)
.map((passage) => {
const words = new Set(terms(`${passage.label} ${passage.text}`));
const hits = wanted.filter((word) => words.has(word));
return { ...passage, score: hits.length, matched: hits };
})
/* A passage that matches nothing is not a weak answer, it is a different
subject. Returning it would put unrelated prose under a question and
let the reader assume it was relevant. */
.filter((passage) => passage.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return {
passages,
source: 'declared',
available: true,
note: passages.length
? null
: `Nothing in ${agent.name}'s knowledge covers that.`,
};
}
/**
* Whether a question can be answered from knowledge at all.
*
* Used to decide whether to say "no source is configured" or "the source has
* nothing on this" — two different answers, and reporting the second when the
* first is true would imply a corpus exists.
*/
export const hasKnowledge = (agent) =>
Boolean(agent && (agent.knowledge || []).some((entry) => entry.body || entry.url));
/** The documents an agent carries, for a configuration screen. */
export const knowledgeDocuments = (agent) =>
(agent?.knowledge || []).map((entry) => ({
id: entry.id,
label: entry.label,
kind: entry.kind,
url: entry.url || '',
chunks: chunk(entry).length,
}));

276
src/lib/agents/registry.js Normal file
View 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);

388
src/lib/agents/runtime.js Normal file
View File

@@ -0,0 +1,388 @@
import { canonicalPage } from '@/lib/skills/surfaces';
import { pageKeyForContext } from '@/lib/skills/registry';
import { getAgent } from './registry';
import { reasoningFor } from './vocabulary';
/**
* The agent runtime.
*
* Its whole job is to *narrow*. The page decides what is in reach; an agent
* decides how much of that reach to use, and can never extend it.
*
* Current PageContext
* ↓
* Agent ← this file
* ↓
* Agent Skills
* ↓
* Allowed Data / Knowledge / Tools
* ↓
* Owliver
*
* The narrowing is arithmetic rather than policy. `getSkillsForPage` filters on
* the page *and* on a list of disabled ids in one expression
* (`skills/registry.js`), so an agent participates only by adding ids to that
* list. There is no code path by which adding an id can make a skill appear —
* which is why "an agent cannot widen a page" is a property of the data flow
* and not a rule someone has to remember to enforce.
*
* Everything downstream — knowledge, tools, the provider request — is derived
* from the scoped skill list rather than from the agent directly, so each of
* them inherits the same boundary without restating it.
*
* The stages below are named and individually callable. Today they are called
* in order by the existing panel; a future orchestrator can drive them in a
* different order without any of them changing, which is the whole reason they
* are separate functions rather than one.
*/
/* ── Scope ──────────────────────────────────────────────────────────────── */
/**
* The skill ids an agent carries, including one level of subagent.
*
* One level, with a visited set. A deeper walk would let a chain of agents
* assemble a skill list nobody wrote down, and the cycle guard is not
* optional — `readAgentRegistry` rejects self-reference, but A→B→A is only
* caught here.
*
* A subagent that is not published contributes nothing: an archived or draft
* agent has been taken out of service, and inheriting its skills through a
* parent would put it back.
*/
export function agentSkillIds(agent, agents = []) {
if (!agent) return [];
const ids = new Set(agent.skills || []);
const seen = new Set([agent.id]);
for (const subId of agent.subagents || []) {
if (seen.has(subId)) continue;
seen.add(subId);
const sub = getAgent(agents, subId);
if (!sub || sub.status !== 'published') continue;
for (const id of sub.skills || []) ids.add(id);
}
return [...ids];
}
/**
* The disabled list an agent implies: everything it does not carry.
*
* **This is the entire mechanism.** Callers pass the result wherever
* `disabledSkills` already goes — `skillsForContext`, `matchSkill`,
* `owliverSuggestions`, `resolveIntent` — and the page filter does the rest.
*
* With no agent the input is returned unchanged, so "no agent selected" is
* byte-for-byte the behaviour the product had before any of this existed. That
* is asserted in `skill-check.mjs` rather than assumed.
*/
export function agentScopedDisabled(agent, skills = [], disabled = []) {
if (!agent) return disabled;
const carried = new Set(agentSkillIds(agent, []));
const withheld = skills
.map((s) => (typeof s === 'string' ? s : s.id))
.filter((id) => id && !carried.has(id));
return [...new Set([...disabled, ...withheld])];
}
/** The same, with subagents resolved against the full registry. */
export function agentScopedDisabledWith(agent, agents, skills = [], disabled = []) {
if (!agent) return disabled;
const carried = new Set(agentSkillIds(agent, agents));
const withheld = skills
.map((s) => (typeof s === 'string' ? s : s.id))
.filter((id) => id && !carried.has(id));
return [...new Set([...disabled, ...withheld])];
}
/* ── Coverage ───────────────────────────────────────────────────────────── */
/** Does this agent cover the page behind this assistant context? */
export function agentCovers(agent, contextId) {
if (!agent || !contextId) return false;
const pageKey = pageKeyForContext(contextId);
if (!pageKey) return false;
const wanted = canonicalPage(pageKey) || pageKey;
return (agent.pages || []).some((p) => (canonicalPage(p) || p) === wanted);
}
/** Published agents covering this page, most specific first. */
export function agentsForContext(agents = [], contextId) {
return agents
.filter((a) => a.status === 'published' && agentCovers(a, contextId))
/* Fewest pages first: a page's own agent is more specific than the root,
and specificity is what makes it the sensible default. */
.sort((a, b) => a.pages.length - b.pages.length || a.name.localeCompare(b.name));
}
/**
* The general agent every page falls back to.
*
* Named once, here, because two different things need it and neither should
* carry its own copy: resolving a default, and deciding whether a page has an
* agent *of its own*.
*/
export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
/**
* The agent written *for* this page, if there is one.
*
* The general agent is deliberately excluded. It covers every surface — which
* is what makes it a fallback — so counting it as a page's own agent would make
* "does this page have a native agent?" true everywhere and the distinction
* meaningless.
*
* Returns null on a page nobody wrote an agent for. That is a normal state, not
* a broken one: see `resolveDefaultAgent`.
*/
export function nativeAgentForContext(agents = [], contextId) {
return agentsForContext(agents, contextId).find((a) => a.id !== FALLBACK_AGENT_ID) || null;
}
/**
* The general agent, when it can answer here.
*
* Falls through to whichever published agent covers the page if the general one
* has been archived or does not list this surface — a page must never be left
* without an agent because of how the registry happens to be configured.
*/
export function fallbackAgentForContext(agents = [], contextId) {
const general = getAgent(agents, FALLBACK_AGENT_ID);
if (general && general.status === 'published' && agentCovers(general, contextId)) return general;
return agentsForContext(agents, contextId)[0] || null;
}
/**
* The agent a page opens with when nobody has chosen one.
*
* Two modes, and the second is the one that was missing:
*
* 1. **The page has an agent of its own** — Positions, Analytics, Activity and
* the five others. That agent answers, because its instructions and skills
* were written for this page.
* 2. **The page has none** — Settings, the workspace surfaces, Agent
* Configure. The *general* agent answers.
*
* "No native agent" is not "no Owliver". A page without a specialist is a page
* the general agent handles, exactly as Owliver handled every page before
* specialists existed. Nothing here can leave a page agent-less, and
* `skill-check` asserts the general agent covers every surface a skill may name,
* so mode 2 always has something to resolve to.
*/
export function resolveDefaultAgent(agents = [], contextId) {
return nativeAgentForContext(agents, contextId) || fallbackAgentForContext(agents, contextId);
}
/**
* The agent a page opens with. Kept as the name every existing caller uses.
*
* Behaviourally identical to what it did before — on a page with its own agent
* that agent is both "first by specificity" and "the native one" — but it now
* says *why* it returns what it returns.
*/
export function defaultAgentForContext(agents = [], contextId) {
return resolveDefaultAgent(agents, contextId);
}
/* ── Selection ──────────────────────────────────────────────────────────── */
/**
* Whether a chosen agent still applies where the reader is now.
*
* A selection is made *somewhere*. Carrying only its id meant a choice made on
* one page followed the reader onto every other one, so choosing the Positions
* Agent on Positions and then opening Settings left Settings constrained by an
* agent nobody had chosen for it — the page looked broken, and the reason was
* invisible.
*
* So a selection carries the context it was made on, and three cases fall out:
*
* - **It covers this page.** It applies. This is a selection working as
* intended, and it survives navigation across every page it covers.
* - **It does not cover this page, but this is where it was chosen.** It
* applies, constrained — the reader picked a specialist here on purpose and
* is owed the honest "this agent does not cover this page" rather than a
* silent swap.
* - **It does not cover this page and was chosen elsewhere.** It is stale.
* It is retired, and the page resolves its own default.
*
* `retire` rather than "ignore for now": a constrained choice that the reader
* has navigated away from is spent. Keeping it would mean returning to that page
* later and finding it constrained by a decision made in a different session of
* attention.
*
* Pure, and takes the selection as a value, so the whole rule is testable
* without a browser, a router or a React tree.
*/
export function resolveSelection(agents = [], selection = null, contextId = null) {
/* A bare id is accepted so an account-level default — which was never chosen
on any page — can be resolved by the same rule. */
const id = typeof selection === 'string' ? selection : selection?.id || null;
const chosenOn = typeof selection === 'string' ? null : selection?.contextId || null;
if (!id) return { id: null, covers: false, retire: false };
const agent = getAgent(agents, id);
/* An agent that no longer exists — deleted, or a stored id from an older
registry. Nothing to apply and nothing worth keeping. */
if (!agent) return { id: null, covers: false, retire: true };
if (agentCovers(agent, contextId)) return { id, covers: true, retire: false };
if (chosenOn && chosenOn === contextId) return { id, covers: false, retire: false };
return { id: null, covers: false, retire: true };
}
/**
* Which agent will actually answer, and why.
*
* Returns the requested agent even when it does not cover the page, together
* with `covers: false` and the page's native agent as `suggestion`. Silently
* swapping in a different agent would be worse than the honest answer: the
* reader chose one, and a panel that quietly answers as another is lying about
* which it is.
*/
export function resolveAgentForTurn(agents = [], activeId, contextId) {
const requested = activeId ? getAgent(agents, activeId) : null;
const native = defaultAgentForContext(agents, contextId);
if (!requested) return { agent: native, covers: Boolean(native), requested: null, suggestion: null };
const covers = agentCovers(requested, contextId);
return {
agent: requested,
covers,
requested,
suggestion: covers ? null : native,
};
}
/* ── Starters ───────────────────────────────────────────────────────────── */
/**
* The chips this agent offers, in the shape the existing `PromptChips` reads.
*
* An agent that does not cover the page offers none: a starter is a promise
* that the question will be answered here, and it would not be.
*/
export function agentStarters(agent, contextId = null) {
if (!agent) return [];
if (contextId && !agentCovers(agent, contextId)) return [];
return (agent.starters || []).map((starter) => ({
label: starter.label,
prompt: starter.prompt || starter.label,
/* No capability: a starter is a question, and which skill answers it is
decided by the same matcher that handles anything typed. Naming one here
would let an agent address a skill the page has not offered. */
capability: null,
source: 'agent',
}));
}
/* ── Question classification ────────────────────────────────────────────── */
/**
* Words that ask what a document says rather than what the records show.
*
* Deliberately narrow. Misreading a structured question as a knowledge one
* costs the reader a real answer and replaces it with a policy quotation, which
* is a worse failure than the reverse — so anything ambiguous stays structured.
*/
const KNOWLEDGE_TERMS = [
'policy', 'policies', 'procedure', 'guideline', 'guidelines', 'handbook',
'rule', 'rules', 'documentation', 'what does it say', 'according to',
'are we allowed', 'am i allowed', 'supposed to',
];
/** Words that ask for a figure out of the records. */
const STRUCTURED_TERMS = [
'how many', 'how much', 'count', 'total', 'average', 'rate', 'trend',
'compare', 'list', 'show me', 'who', 'which', 'when', 'breakdown', 'summary',
'exceeded', 'more than', 'less than', 'over', 'under',
];
/**
* Whole-word matching, not substring.
*
* `includes` is wrong here and wrong in a way that is hard to see: "overtime"
* contains "over", so "what does our overtime policy say?" matched a
* comparison term and was classified as needing records. A question about a
* document would have been answered with a table.
*
* Word boundaries on both ends, so a phrase still matches inside a sentence but
* a term never matches inside a longer word.
*/
const hasAny = (text, terms) => terms.some((term) => {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b${escaped}\\b`).test(text);
});
/**
* Which sources a question needs.
*
* Three answers, and the distinction matters because they read different
* things:
*
* - `structured` — "which employees worked more than 20 overtime hours" is a
* query over records. It goes to the data resolvers. **Never** to retrieval:
* Krow's operational records are not embedded, and answering this from
* prose would produce a confident number nobody can trace.
* - `knowledge` — "what does our overtime policy say" is a question about a
* document.
* - `combined` — "which employees exceeded the overtime policy this month"
* needs both, and the runtime composes them.
*
* Keyword matching, like every other matcher in this product: the answers are
* computed locally and deterministically, so the routing has to be inspectable
* in the same way.
*/
export function classifyQuestion({ question = '' } = {}) {
const text = String(question).toLowerCase();
const knowledge = hasAny(text, KNOWLEDGE_TERMS);
const structured = hasAny(text, STRUCTURED_TERMS);
if (knowledge && structured) return 'combined';
if (knowledge) return 'knowledge';
return 'structured';
}
/* ── Reasoning ──────────────────────────────────────────────────────────── */
/**
* How much work this turn is worth, as a number.
*
* Read off the agent's declared mode so the runtime never branches on a mode
* name. `balanced` is the default and is deliberately today's behaviour, so an
* agent that says nothing about reasoning answers exactly as the panel does now.
*/
export function reasoningDepth(agent) {
return reasoningFor(agent?.reasoning)?.depth ?? 2;
}
/**
* What the runtime tells the provider about the agent.
*
* Deliberately small and serializable: an id, the instructions, the mode. Not
* the skill list, and not the records — the provider is handed what the agent
* *is*, and the data it may read has already been decided by the page.
*/
export function agentRequest(agent, contextId = null) {
if (!agent) return null;
return {
id: agent.id,
name: agent.name,
instructions: agent.instructions || '',
trigger: agent.trigger || '',
reasoning: agent.reasoning,
depth: reasoningDepth(agent),
webSearch: Boolean(agent.webSearch),
covers: contextId ? agentCovers(agent, contextId) : true,
};
}

137
src/lib/agents/useAgents.js Normal file
View File

@@ -0,0 +1,137 @@
import { useCallback, useMemo } from 'react';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { reportSave } from '@/lib/skills/saveFeedback';
import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry';
import { customAgentSource, removeCustomAgent, upsertCustomAgent } from './customAgents';
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
/**
* Reading and writing agents, in one place.
*
* Every screen that changes an agent goes through here. That is deliberate: the
* list can publish, the detail page can publish, and two implementations of
* "publish" would eventually disagree about what publishing means.
*
* Storage is the account's preferences, holding **Markdown** — the same
* artefact a file in `src/agents/` is. The forms never expose it: a person
* fills in fields, `agentPatch` writes them into frontmatter, and the parser
* reads them back. Markdown stays the definition format without ever being
* something an HR user has to see.
*
* Editing a shipped agent writes an account definition of the same id, which
* the registry reports as `shadowed`. That is the existing override mechanism,
* not a new one — a shipped agent is never mutated on disk.
*/
export function useAgents() {
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const stored = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
const { agents, diagnostics } = useMemo(
() => readAgentRegistry(stored, { customSkills }),
[stored, customSkills]
);
/** Whether this id is shipped with the product rather than authored here. */
const isShipped = useCallback((id) => AGENTS.some((a) => a.id === id), []);
/** Whether the account has its own definition for this id. */
const isOverridden = useCallback((id) => customAgentSource(stored, id) !== null, [stored]);
/**
* The Markdown behind an agent.
*
* The account's own copy when there is one, otherwise the shipped definition —
* so editing a shipped agent starts from what it actually says rather than
* from a blank.
*/
const sourceFor = useCallback(
(id) => customAgentSource(stored, id) || AGENTS.find((a) => a.id === id)?.markdown || null,
[stored]
);
/**
* Writes a definition.
*
* Validated first and refused with a message rather than stored broken: a
* definition that cannot be read is an agent that silently stops existing.
* Returns `{ ok, error }` so a form can stay on screen and say why.
*/
const save = useCallback(async (/** @type {string} */ source, /** @type {any} */ { message } = {}) => {
const problem = validateAgentSource(source);
if (problem) return { ok: false, error: problem };
const { agent, next } = upsertCustomAgent(stored, source);
await updatePreferences.mutateAsync(
{ customAgents: next },
reportSave(message || `${agent.name} saved`)
);
return { ok: true, agent };
}, [stored, updatePreferences]);
/** Removes the account's definition. A shipped agent returns to its shipped form. */
const remove = useCallback(async (id) => {
const next = removeCustomAgent(stored, id);
await updatePreferences.mutateAsync(
{ customAgents: next },
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed')
);
return { ok: true };
}, [stored, updatePreferences, isShipped]);
/**
* Publishes, refusing to overwrite a newer published version.
*
* Returns `{ ok: false, conflict }` when the stored definition has moved on,
* so the screen can say what would be lost instead of losing it.
*/
const publish = useCallback(async (id) => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to publish.' };
const live = agents.find((a) => a.id === id);
const result = publishAgent(source, {
publishedVersion: live?.status === 'published' ? live.version : 0,
});
if (result.conflict) return { ok: false, conflict: result.conflict };
return save(result.source, { message: `${live?.name || id} published` });
}, [sourceFor, agents, save]);
const archive = useCallback(async (id) => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to archive.' };
return save(archiveAgent(source), { message: `${agents.find((a) => a.id === id)?.name || id} archived` });
}, [sourceFor, agents, save]);
const restore = useCallback(async (id) => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to restore.' };
return save(restoreAgent(source), { message: 'Restored as a draft' });
}, [sourceFor, save]);
const duplicate = useCallback(async (id) => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to copy.' };
const copy = duplicateAgent(source, { existingIds: agents.map((a) => a.id) });
const result = await save(copy, { message: 'Copy created as a draft' });
return result.ok ? { ...result, id: parseAgent(copy, { custom: true }).id } : result;
}, [sourceFor, agents, save]);
return {
agents,
diagnostics,
saving: updatePreferences.isPending,
isShipped,
isOverridden,
sourceFor,
save,
remove,
publish,
archive,
restore,
duplicate,
};
}

View File

@@ -0,0 +1,117 @@
/**
* The closed vocabulary an agent definition is allowed to use.
*
* This file is to agents what `surfaces.js` is to skills: a hand-written table
* of every value a definition may name, and nothing else. An agent definition
* is configuration, and the way it stays configuration is that nothing here is
* looked up dynamically, evaluated, or turned into a component — a definition
* names a key, and this file answers whether that key exists.
*
* Icons are ids only. The id → component table lives beside the components
* that draw them, for the same reason `SECTION_COMPONENTS` does: a definition
* must never be able to reach a component nobody wrote down.
*/
/* ── Lifecycle ──────────────────────────────────────────────────────────── */
/**
* Where an agent is in its life.
*
* `draft` is the default for anything authored, so creating an agent never
* publishes one. Only `published` agents are offered in the switcher or
* resolved as a page's native agent — an archived agent keeps its definition
* and stops answering.
*/
export const AGENT_STATUSES = ['draft', 'published', 'archived'];
export const DEFAULT_AGENT_STATUS = 'draft';
/* ── Reasoning ──────────────────────────────────────────────────────────── */
/**
* How much work an answer is worth.
*
* `depth` is the only thing the runtime reads, so a mode is a number with a
* name rather than a branch: adding one is a row here, not a condition
* somewhere else. `balanced` is the default and is deliberately today's
* behaviour, so an agent that says nothing about reasoning answers exactly as
* the panel does now.
*/
export const REASONING_MODES = [
{
id: 'fast',
label: 'Fast',
depth: 1,
summary: 'Use for simple questions and quick summaries.',
},
{
id: 'balanced',
label: 'Balanced',
depth: 2,
summary: 'Default mode for normal workforce analysis.',
},
{
id: 'deep',
label: 'Deep',
depth: 3,
summary: 'Use for complex multi-source analysis.',
},
];
export const SUPPORTED_REASONING = REASONING_MODES.map((m) => m.id);
export const DEFAULT_REASONING = 'balanced';
export const reasoningFor = (id) =>
REASONING_MODES.find((m) => m.id === String(id || '').trim()) || null;
export const reasoningLabel = (id) => reasoningFor(id)?.label || id;
/* ── Knowledge ──────────────────────────────────────────────────────────── */
/**
* The kinds of thing an agent can be told, as distinct from what it can *do*.
*
* Knowledge is reference material an author wrote down; a skill is a capability
* that reads live records. Keeping the two vocabularies apart is what stops a
* knowledge entry being mistaken for a computed figure — see `knowledge.js`.
*/
export const KNOWLEDGE_KINDS = ['note', 'link', 'skill-reference'];
export const DEFAULT_KNOWLEDGE_KIND = 'note';
/* ── Permissions ────────────────────────────────────────────────────────── */
/** Who may reach an agent. */
export const AGENT_ACCESS = ['all', 'specific'];
export const DEFAULT_AGENT_ACCESS = 'all';
/** What a named person may do with it. */
export const PERMISSION_ROLES = ['manager', 'editor', 'viewer'];
export const DEFAULT_PERMISSION_ROLE = 'viewer';
/* ── Icons ──────────────────────────────────────────────────────────────── */
/**
* The icons an agent may name.
*
* Ids, not components. Deliberately small — an agent is identified by its name
* first, and a long list only makes two agents easier to confuse.
*/
export const AGENT_ICONS = [
'owliver',
'sparkles',
'briefcase',
'users',
'user-check',
'layers',
'graduation-cap',
'bar-chart',
'activity',
'shield',
];
export const DEFAULT_AGENT_ICON = 'owliver';
export const isAgentIcon = (id) => AGENT_ICONS.includes(String(id || '').trim());