update agents skill design
This commit is contained in:
313
src/lib/agents/agentConfig.js
Normal file
313
src/lib/agents/agentConfig.js
Normal 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,
|
||||
};
|
||||
}
|
||||
209
src/lib/agents/agentFields.js
Normal file
209
src/lib/agents/agentFields.js
Normal 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);
|
||||
}
|
||||
83
src/lib/agents/agentLifecycle.js
Normal file
83
src/lib/agents/agentLifecycle.js
Normal 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
114
src/lib/agents/context.js
Normal 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,
|
||||
});
|
||||
153
src/lib/agents/conversationInsights.js
Normal file
153
src/lib/agents/conversationInsights.js
Normal 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,
|
||||
});
|
||||
114
src/lib/agents/customAgents.js
Normal file
114
src/lib/agents/customAgents.js
Normal 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
157
src/lib/agents/knowledge.js
Normal 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
276
src/lib/agents/registry.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
allSkills, normalizeDefinition, parseFrontmatter, sectionBullets, sectionSource, sectionText,
|
||||
} from '@/lib/skills/registry';
|
||||
import { slugify } from '@/lib/skills/uiConfig';
|
||||
import { canonicalPage } from '@/lib/skills/surfaces';
|
||||
import { normalizeAgent } from './agentConfig';
|
||||
|
||||
/**
|
||||
* The agent registry.
|
||||
*
|
||||
* An agent is a Markdown file, for the same reason a skill is: frontmatter
|
||||
* declares what it is and what it carries, the body says how it should behave.
|
||||
* Nothing here executes Markdown — an agent names skill ids, and the skill
|
||||
* registry decides what those ids mean.
|
||||
*
|
||||
* **This is not a second parser.** `parseFrontmatter`, `normalizeDefinition`
|
||||
* and the three section readers are imported from the skill registry, so an
|
||||
* agent file and a skill file are read by exactly the same code and cannot
|
||||
* drift into two formats. The BOM, CRLF, blank-line and trailing-space
|
||||
* tolerance a skill definition gets is the tolerance an agent definition gets,
|
||||
* because it is the same function.
|
||||
*
|
||||
* Registration is the filesystem, as it is for skills: `import.meta.glob`
|
||||
* picks up everything under `src/agents/`, so a new agent is registered by
|
||||
* existing.
|
||||
*
|
||||
* What an agent may *do* with what it carries is `runtime.js`'s decision, and
|
||||
* the boundary it cannot cross is the page's — an agent contributes to the
|
||||
* disabled list and nothing else, so it can only ever narrow a page.
|
||||
*/
|
||||
|
||||
/* `import.meta.glob` is a Vite build-time API. `tsc` models only the standard
|
||||
`ImportMeta`, so it reports this as a missing property — a false positive
|
||||
rather than a defect. Suppressed rather than cast: a cast would assert a type
|
||||
nothing here can verify, and this file is the one place the glob appears. */
|
||||
// @ts-ignore -- Vite build-time API, absent from the standard ImportMeta type
|
||||
const FILES = import.meta.glob('/src/agents/**/*.md', { query: '?raw', import: 'default', eager: true });
|
||||
|
||||
/**
|
||||
* One Markdown definition → one agent.
|
||||
*
|
||||
* Shared by the files on disk and by anything authored at runtime, so an agent
|
||||
* written in the editor is parsed by the same code as a shipped one.
|
||||
*/
|
||||
export function parseAgent(raw, { path = 'custom', custom = false } = {}) {
|
||||
const { data, body } = parseFrontmatter(raw);
|
||||
|
||||
/**
|
||||
* The definition's id.
|
||||
*
|
||||
* `id:` when written, and it always wins — an id is the address skills,
|
||||
* subagents and stored preferences refer to, and deriving over the top of
|
||||
* one would silently rename an agent. Falling back to a slug of the name
|
||||
* matches what an author means by leaving it out; falling back to the
|
||||
* filename is right only for a file, which is why it is last.
|
||||
*/
|
||||
const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, '');
|
||||
|
||||
const { agent, errors } = normalizeAgent(data, {
|
||||
agentId: id,
|
||||
/* Instructions are the body's own section, not a frontmatter string: they
|
||||
are prose, and prose belongs under a heading where it can be written
|
||||
and read as prose. */
|
||||
body: sectionSource(body, 'Instructions') || '',
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
name: data.name || 'Untitled agent',
|
||||
description: data.description || '',
|
||||
...agent,
|
||||
/* What this definition lost on the way in. Carried on the record rather
|
||||
than thrown, so one bad entry costs its author that entry and a message
|
||||
instead of the whole file — and so `readAgentRegistry` can report it for
|
||||
shipped definitions too, which are otherwise never re-checked. */
|
||||
errors,
|
||||
/* What this agent is for, as bullets, for the switcher and the detail
|
||||
page. Read with the same section reader skills use. */
|
||||
purpose: sectionBullets(body, 'Purpose'),
|
||||
summary: sectionText(body, 'Purpose'),
|
||||
/* The definition as written. The editor edits this; everything else reads
|
||||
the parsed form, so there is one artefact behind all of them. */
|
||||
markdown: raw,
|
||||
source: custom ? 'account' : 'repository',
|
||||
path,
|
||||
body,
|
||||
custom,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every agent on disk, parsed once at module load. */
|
||||
export const AGENTS = Object.entries(FILES)
|
||||
.map(([path, raw]) => parseAgent(raw, { path }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/**
|
||||
* The registry as actually assembled, with everything that went wrong
|
||||
* assembling it.
|
||||
*
|
||||
* The same four failure modes the skill registry reports, because they are the
|
||||
* same four failures and all of them are silent:
|
||||
*
|
||||
* - `unreadable` — a stored definition that will not parse. Dropped without
|
||||
* a word, an agent simply stops existing and the list looks healthy.
|
||||
* - `incomplete` — a definition that parses but lost a field on the way in.
|
||||
* - `shadowed` — an account definition replacing a shipped one of the same
|
||||
* id. Intended, and indistinguishable from the shipped one having broken.
|
||||
* - `unattached` — an agent naming a skill id no registered skill carries.
|
||||
* This is the one that matters most here: a skill renamed or removed
|
||||
* leaves every agent that named it quietly carrying one capability fewer.
|
||||
*/
|
||||
export function readAgentRegistry(customSources = [], { customSkills = [] } = {}) {
|
||||
const diagnostics = [];
|
||||
const custom = [];
|
||||
|
||||
customSources.forEach((entry, i) => {
|
||||
const path = entry?.path || `custom/${i}.md`;
|
||||
let agent = null;
|
||||
try {
|
||||
agent = parseAgent(entry?.raw ?? entry, { path, custom: true });
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
agentId: null,
|
||||
message: `A stored agent could not be read and is not registered. ${error?.message || ''}`.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!agent?.id) {
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unreadable',
|
||||
path,
|
||||
agentId: null,
|
||||
message: 'A stored agent has no `id` and is not registered.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
custom.push(agent);
|
||||
});
|
||||
|
||||
const byId = new Map(AGENTS.map((a) => [a.id, a]));
|
||||
for (const agent of custom) {
|
||||
if (byId.has(agent.id) && !byId.get(agent.id).custom) {
|
||||
diagnostics.push({
|
||||
level: 'warning',
|
||||
kind: 'shadowed',
|
||||
path: agent.path,
|
||||
agentId: agent.id,
|
||||
message: `\`${agent.id}\` replaces the built-in agent of the same id. The built-in definition is not registered.`,
|
||||
});
|
||||
}
|
||||
byId.set(agent.id, agent);
|
||||
}
|
||||
|
||||
const agents = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/* A definition that lost part of itself on the way in. Reported for every
|
||||
agent, not only stored ones, so a file on disk that stops resolving after
|
||||
a vocabulary change is just as visible. */
|
||||
for (const agent of agents) {
|
||||
for (const message of agent.errors || []) {
|
||||
diagnostics.push({
|
||||
level: 'error', kind: 'incomplete', path: agent.path, agentId: agent.id, message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* Skills and subagents that do not resolve. Both are addresses, and an
|
||||
address that points at nothing is the failure this catches. */
|
||||
const skillIds = new Set(allSkills(customSkills).map((s) => s.id));
|
||||
const agentIds = new Set(agents.map((a) => a.id));
|
||||
|
||||
for (const agent of agents) {
|
||||
for (const skillId of agent.skills) {
|
||||
if (skillIds.has(skillId)) continue;
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unattached',
|
||||
path: agent.path,
|
||||
agentId: agent.id,
|
||||
message: `\`${agent.id}\` names the skill \`${skillId}\`, which no registered skill provides.`,
|
||||
});
|
||||
}
|
||||
for (const subId of agent.subagents) {
|
||||
if (agentIds.has(subId)) continue;
|
||||
diagnostics.push({
|
||||
level: 'error',
|
||||
kind: 'unattached',
|
||||
path: agent.path,
|
||||
agentId: agent.id,
|
||||
message: `\`${agent.id}\` names the subagent \`${subId}\`, which no registered agent provides.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { agents, diagnostics };
|
||||
}
|
||||
|
||||
/** Every agent, shipped and stored. */
|
||||
export function allAgents(customSources = [], options = {}) {
|
||||
return readAgentRegistry(customSources, options).agents;
|
||||
}
|
||||
|
||||
/** Everything that went wrong assembling it, for the management page. */
|
||||
export function agentDiagnostics(customSources = [], options = {}) {
|
||||
return readAgentRegistry(customSources, options).diagnostics;
|
||||
}
|
||||
|
||||
/** One agent by id, or null. */
|
||||
export const getAgent = (agents, id) =>
|
||||
(agents || []).find((a) => a.id === id) || null;
|
||||
|
||||
/** The agents an author may actually pick: published, never archived. */
|
||||
export const publishedAgents = (agents = []) =>
|
||||
agents.filter((a) => a.status === 'published');
|
||||
|
||||
/**
|
||||
* Agents matching a search, over the fields a person would search by.
|
||||
*
|
||||
* An empty query is every agent rather than none — the switcher opens with the
|
||||
* box empty, and an empty list would read as "there are no agents".
|
||||
*/
|
||||
export function searchAgents(agents = [], query = '') {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
if (!q) return agents;
|
||||
return agents.filter((a) =>
|
||||
[a.name, a.description, a.trigger, ...(a.purpose || [])]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a definition before it is stored. Returns an error string or null.
|
||||
*
|
||||
* The order is the order an author would fix things in, which is what
|
||||
* `validateSkillSource` does and why this reads the same way.
|
||||
*
|
||||
* Deliberately *not* refused: an agent carrying no skills. Five of this
|
||||
* product's pages have no Owliver skills at all and answer from their own page
|
||||
* responder, so refusing a skill-less agent would mean inventing placeholder
|
||||
* skills to make those pages configurable. See the note in `agentConfig.js`.
|
||||
*/
|
||||
export function validateAgentSource(raw) {
|
||||
if (!String(raw ?? '').trim()) return 'Paste or upload a Markdown definition.';
|
||||
|
||||
let agent;
|
||||
try {
|
||||
agent = parseAgent(raw, { custom: true });
|
||||
} catch (error) {
|
||||
/* The YAML subset reports the line it failed on, which is far more useful
|
||||
than "could not be parsed". */
|
||||
return `That definition could not be parsed. ${error?.message || ''}`.trim();
|
||||
}
|
||||
|
||||
if (!agent.id) return 'The frontmatter needs an `id`.';
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) {
|
||||
return 'The `id` must be lower-case letters, numbers and dashes.';
|
||||
}
|
||||
if (!raw.includes('name:') || agent.name === 'Untitled agent') {
|
||||
return 'The frontmatter needs a `name`.';
|
||||
}
|
||||
if (!agent.pages.length) {
|
||||
return 'An agent needs at least one `pages:` entry, or it can never be offered anywhere.';
|
||||
}
|
||||
if (agent.errors?.length) return agent.errors[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Page keys an agent may cover, for the editor's own guidance. */
|
||||
export const agentPageKeys = (pages = []) =>
|
||||
pages.map((p) => canonicalPage(p) || p).filter(Boolean);
|
||||
388
src/lib/agents/runtime.js
Normal file
388
src/lib/agents/runtime.js
Normal 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
137
src/lib/agents/useAgents.js
Normal 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,
|
||||
};
|
||||
}
|
||||
117
src/lib/agents/vocabulary.js
Normal file
117
src/lib/agents/vocabulary.js
Normal 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());
|
||||
Reference in New Issue
Block a user