update owliver agent

This commit is contained in:
2026-08-24 20:06:11 +05:30
parent 02eb48af99
commit 6d8b4dbf36
28 changed files with 3176 additions and 692 deletions

View File

@@ -0,0 +1,181 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
import { allSkills, matchSkill, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
import { canonicalPage, surfaceFor } from '@/lib/skills/surfaces';
import { toolsForContext } from '@/lib/skills/tools';
import { agentCovers, agentScopedDisabled } from './runtime';
/**
* Trying a capability before committing to it.
*
* Two things have to be true for this to be worth having, and both are about
* refusing to fake something:
*
* 1. **The evaluation is the live runtime.** `agentCovers`, `skillsForContext`,
* `agentScopedDisabled`, `matchSkill` and `toolsForContext` are the same
* functions the assistant routes every real question through. Nothing here
* re-implements any of them, so a green result cannot mean something
* different from what will happen on the page.
*
* 2. **Testing is not saving.** The agent this reasons about is *hypothetical*
* — the draft's fields with the candidate capability added — and it is
* never written anywhere. A reader can try six capabilities and leave with
* the agent exactly as they found it.
*
* The other half of the test is the answer itself, and that is deliberately not
* here: `scopeFor` returns what `send({ scope })` needs so the question runs in
* the *existing* Owliver panel against the target page. One conversation, one
* pipeline, no second chat.
*/
/** contextId → the page key it stands for, resolved once. */
const CONTEXTS = Object.values(PLACEMENT_ROUTES).map((contextId) => ({
contextId,
pageKey: pageKeyForContext(contextId),
}));
/** The assistant context for a surface, or null if the page carries no panel. */
export function contextForPage(pageKey) {
const wanted = canonicalPage(pageKey) || pageKey;
const hit = CONTEXTS.find((c) => c.pageKey && (canonicalPage(c.pageKey) || c.pageKey) === wanted);
return hit?.contextId || null;
}
/**
* The surfaces a capability can actually be tried on, for this agent.
*
* The intersection of what the agent covers and what the capability declares —
* anywhere else the runtime would decline, and offering it as a test target
* would be offering a test guaranteed to fail for a reason that is not about
* the capability.
*/
export function testTargets(agentPages = [], skillPages = []) {
const skill = new Set((skillPages || []).map((p) => canonicalPage(p) || p));
return (agentPages || [])
.map((p) => canonicalPage(p) || p)
.filter((p) => skill.has(p))
.map((pageKey) => ({
pageKey,
contextId: contextForPage(pageKey),
label: surfaceFor(pageKey)?.label || pageKey,
}))
.filter((t) => t.contextId);
}
/**
* The agent as it *would* be with this capability attached.
*
* Published on purpose: an unpublished draft is refused by the runtime for a
* reason that has nothing to do with the capability being tried, and a test
* that always says "this agent is not published" answers the wrong question.
*/
function hypotheticalAgent(fields, skillId) {
const skills = skillId && !fields.skills.includes(skillId)
? [...fields.skills, skillId]
: fields.skills;
return {
id: fields.id || 'draft',
name: fields.name || 'This agent',
pages: fields.pages,
skills,
subagents: fields.subagents || [],
knowledge: fields.knowledge || [],
starters: fields.starters || [],
reasoning: fields.reasoning,
webSearch: fields.webSearch,
status: 'published',
};
}
/**
* What the runtime would do with this question, on this page, with this
* capability added.
*
* `matched` is the honest headline: it is the skill that would actually answer.
* When that is the capability under test, the test proves the capability;
* when it is a different one, it says so rather than claiming a pass — two
* definitions claiming the same phrase is a real condition and the reader
* should see it here rather than discover it in production.
*/
export function evaluateCapability({
fields, skillId, contextId, question, customSkills = [], entry = null,
}) {
const agent = hypotheticalAgent(fields, skillId);
const registry = allSkills(customSkills);
const disabledSkills = agentScopedDisabled(agent, registry, []);
const covers = agentCovers(agent, contextId);
const reachable = covers ? skillsForContext(contextId, disabledSkills, customSkills) : [];
const offered = reachable.some((s) => s.id === skillId);
/**
* There are two ways a question reaches a skill, and conflating them made
* this panel lie about its own suggestions.
*
* A **typed** question is routed by `matchSkill` against declared triggers. A
* **suggestion** is not routed at all: it is a chip, it carries the capability
* it asks for, and `send` short-circuits intent resolution for exactly that
* reason (`capability ? { kind: 'answer' }`). So "What kinds of event are
* there?" — a suggestion Activity Analysis declares — matches none of its
* triggers and is still answered by it every time.
*
* Reporting that as "no skill claims this wording" was true of the matcher and
* false of the product. Both paths are modelled here, and the one that applies
* is named, so the reader is told *how* it would be answered rather than being
* shown a warning about a question that works.
*/
const declared = (entry?.suggestions || []).find(
(sug) => (sug.prompt || sug.label) === question
);
const matched = covers && question && !declared
? matchSkill(question, contextId, disabledSkills, customSkills)
: null;
return {
agent,
disabledSkills,
covers,
/** Is the capability under test offered at all on this page? */
reachable: offered,
reachableCount: reachable.length,
/** The skill that would answer a *typed* question, if any. */
matched,
/** True when the capability being tested is the one that answers. */
claims: Boolean(offered && (declared || (matched && matched.id === skillId))),
/** How it would be answered: its own suggestion, or a matched trigger. */
route: declared ? 'suggestion' : matched ? 'trigger' : null,
/** The capability a suggestion asks for — passed to `send` as a chip would. */
capability: declared?.capability || null,
tools: covers ? toolsForContext(contextId, disabledSkills, customSkills) : [],
pageLabel: ASSISTANT_CONTEXTS[contextId]?.page || contextId,
};
}
/**
* What `send({ scope })` needs to run this turn against the target page.
*
* Deliberately the same `disabledSkills` the evaluation used, so the answer the
* reader reads is produced under exactly the conditions the diagnostics above
* described.
*/
export const scopeFor = (evaluation, contextId) => ({
contextId,
disabledSkills: evaluation.disabledSkills,
agent: evaluation.agent,
});
/**
* Questions worth trying, taken from the capability itself.
*
* A definition's `owliver.suggestions` are the questions its author wrote it to
* answer, so they are the fairest test of it — and they keep the test from
* being a blank box the reader has to guess at. A definition with none falls
* back to its own name, which is what its default trigger matches.
*/
export function testQuestions(entry) {
const declared = (entry?.suggestions || []).map((s) => s.prompt || s.label).filter(Boolean);
if (declared.length) return declared.slice(0, 3);
return entry?.name ? [entry.name] : [];
}

431
src/lib/skills/catalog.js Normal file
View File

@@ -0,0 +1,431 @@
import { aiAgentSkills, allSkills, getSkillsForPage, skillsWithFacet } from './registry';
import { canonicalPage, placementLabel, sectionTypeLabel, surfaceFor } from './surfaces';
import { describeTool } from './tools';
/**
* The skill catalog — the registry, read as something a person browses.
*
* There is **no second list of skills here**. Every entry is a definition
* `allSkills` already returned, and every field on it is read off that
* definition: what it can be asked for comes from its `owliver.capabilities`,
* what it can do comes from its `actions`, and where it applies comes from its
* `pages`. A catalog that carried its own copy of any of that would eventually
* offer an agent a skill the runtime does not have — which is the exact failure
* `AddSkillsModal` was written to avoid, and this keeps.
*
* What it adds is *grouping*. A definition may declare `category:` and eleven of
* them do; the rest are placed by what they demonstrably are, never by their id.
* That rule matters: the registry is explicit that nothing downstream may test a
* page or a skill by name, so a definition authored tomorrow has to land in a
* group without this file being edited. It does — every step below reads a
* declaration.
*/
/**
* The groups, in the order a reader meets them.
*
* Deliberately Krow's own domains rather than a generic "Core / Development /
* Data" taxonomy: this workspace hires and rosters people, and a category
* called Development would be a heading with nothing under it.
*
* A definition may still declare a `category:` outside this list — the registry
* keeps that field free text on purpose — and `catalogGroups` surfaces it beside
* these rather than dropping it.
*/
export const SKILL_GROUPS = [
{
id: 'analytics',
label: 'Analytics',
blurb: 'The workspace as figures — trends, coverage and the headline picture.',
},
{
id: 'hiring',
label: 'Hiring',
blurb: 'The pipeline: open roles, applicants, and who has already been hired.',
},
{
id: 'workforce',
label: 'Workforce',
blurb: 'The people already on the roster — attendance, hours and training.',
},
{
id: 'operations',
label: 'Operations',
blurb: 'What is happening now, and what is going wrong.',
},
{
id: 'authoring',
label: 'Authoring',
blurb: 'Skills that create a record from the conversation rather than reading one.',
},
];
const GROUP_BY_ID = new Map(SKILL_GROUPS.map((g) => [g.id, g]));
/**
* The group a surface belongs to.
*
* Keyed on the closed surface vocabulary rather than on skill ids, so this is a
* statement about the product's pages — which are a fixed set — and not about
* any particular definition. A skill attaching to a surface listed here inherits
* its group for free.
*/
const GROUP_BY_SURFACE = {
'control-center': 'analytics',
analytics: 'analytics',
positions: 'hiring',
'create-position': 'hiring',
candidates: 'hiring',
'candidates-analysis': 'hiring',
'hired-history': 'hiring',
'talent-pool': 'hiring',
activity: 'operations',
'krow-forge': 'workforce',
profile: 'workforce',
};
/**
* Whether this definition *writes* rather than reads.
*
* Read off two declarations, both of which mean the same thing in different
* words: a `prompt:` is a definition offering to start a piece of work, and an
* action the tool table marks as needing approval is one that changes a record.
* Either makes a skill an authoring skill, and neither is a name.
*/
function isAuthoring(skill) {
if (skill.prompt) return true;
return (skill.actions || []).some((name) => {
const tool = describeTool(name);
return Boolean(tool && (tool.mutates || tool.requiresApproval));
});
}
/**
* Which group a definition belongs to.
*
* Its own `category:` always wins — an author who wrote one has already
* answered this question. Everything after that is inference, in the order of
* how much the definition is actually saying: what it does, then where it
* applies, then nothing.
*/
export function groupFor(skill) {
const declared = String(skill.category || '').trim().toLowerCase();
if (declared) return declared;
if (isAuthoring(skill)) return 'authoring';
for (const page of skill.pages || []) {
const group = GROUP_BY_SURFACE[page];
if (group) return group;
}
return 'general';
}
/** A group's label, whether it is one of ours or one an author invented. */
export const groupLabel = (id) => GROUP_BY_ID.get(id)?.label
|| String(id || '').replace(/[-_]/g, ' ').replace(/^./, (c) => c.toUpperCase());
/**
* What a skill can be asked for, in the reader's words.
*
* The `owliver:` capabilities are the machine-readable half — `summary`,
* `table`, `flow` — and the `## Capabilities` bullets are the sentence the
* author wrote. Both are shown, because one says what shape an answer takes and
* the other says what the answer is about.
*/
function capabilitiesOf(skill) {
const declared = skill.owliver?.capabilities || [];
return {
/** Response shapes this skill offers in the panel. */
shapes: [...declared],
/** The author's own description of what it can do. */
described: skill.capabilities || [],
};
}
/** The tools a skill reaches, described — never a tool it did not declare. */
function toolsOf(skill) {
return (skill.actions || [])
.map((name) => describeTool(name) || { name, label: name, summary: '', readOnly: true })
.filter(Boolean);
}
/** The surfaces a skill answers on, as the product names them. */
function surfacesOf(skill) {
return (skill.pages || []).map((page) => ({
id: page,
label: surfaceFor(page)?.label || page,
}));
}
/**
* One catalog entry: a definition, plus the readings a card and a details panel
* need. Nothing is invented — every field traces back to the parsed skill.
*/
export function catalogEntry(skill) {
const group = groupFor(skill);
return {
id: skill.id,
name: skill.name,
description: skill.description,
group,
groupLabel: groupLabel(group),
/** Owliver, Board, or both — from the definition's own facets. */
type: capabilityType(skill),
capabilities: capabilitiesOf(skill),
tools: toolsOf(skill),
surfaces: surfacesOf(skill),
/** Questions this skill was written to be asked, offered as chips. */
suggestions: (skill.owliver?.suggestions || []).map((s) => ({
label: s.label,
prompt: s.prompt,
capability: s.capability ?? null,
})),
/** How many questions its guided flow asks, when it has one. */
questions: (skill.conversation || []).length,
prompt: skill.prompt || null,
custom: Boolean(skill.custom),
status: skill.status,
};
}
/**
* Every skill an agent may carry, as catalog entries.
*
* The same filter `AddSkillsModal` applied, kept exactly: assistant skills with
* the `owliver` facet and an active status. A workforce training path is
* something a *person* learns — offering one here would promise an agent
* behaviour that does not exist.
*/
export function skillCatalog(customSkills = [], { pages = null } = {}) {
/* `pages: null` means "the whole registry" and is what the skill library
wants. An agent always passes its scope, and gets only what it could
actually use — see `compatibleSkills`. */
const source = pages
? compatibleSkills(pages, customSkills)
: aiAgentSkills(allSkills(customSkills));
return skillsWithFacet(source, 'owliver')
.filter((s) => s.status === 'active')
.map(catalogEntry)
.sort((a, b) => a.name.localeCompare(b.name));
}
/* ── Scope, and what is compatible with it ────────────────────────────────
*
* The fix for the catalog that offered every agent all eighteen skills.
*
* An agent is not a container you may put anything in. It answers on a set of
* surfaces, a skill declares the surfaces it answers on, and the overlap is the
* only set that can ever do anything. Offering Create Position to the Activity
* Agent was not merely noisy — it was offering an attachment the runtime would
* then refuse, because `skillsForContext` filters by page *before* an agent's
* own list is ever consulted. The catalog was advertising attachments that
* could not work.
*
* **One function over declared data, not eight lists.** `getSkillsForPage` is
* the registry's own answer to "what belongs here" and is what the live
* assistant already routes through, so the catalog and the runtime cannot
* disagree by construction. Nothing below tests an agent by name, and a surface
* or a skill added tomorrow is scoped correctly without this file being edited.
*/
/** The surfaces an agent answers on, canonicalized so aliases resolve. */
export const scopeOf = (pages = []) =>
[...new Set(pages.map((p) => canonicalPage(p) || p).filter(Boolean))];
/**
* Every skill compatible with a scope — the union over its surfaces.
*
* Union rather than intersection: an agent covering Positions and Create
* Position may use a skill answering on either, which is exactly what the
* runtime does when the reader is standing on one of them.
*/
export function compatibleSkills(pages = [], customSkills = []) {
const seen = new Map();
for (const page of scopeOf(pages)) {
for (const skill of getSkillsForPage(page, { customSources: customSkills, kind: 'assistant' })) {
if (!seen.has(skill.id)) seen.set(skill.id, skill);
}
}
return [...seen.values()];
}
/** Is this capability usable by an agent with this scope? */
export const isCompatible = (skillId, pages = [], customSkills = []) =>
compatibleSkills(pages, customSkills).some((s) => s.id === skillId);
/**
* Which surfaces a capability offers itself on — Owliver, Board, or both.
*
* Read off the facets the parser already derives from what the definition
* declares. There is deliberately **no new `capabilityType:` field**: a second
* place to say the same thing is a second place for it to be wrong, and a
* definition that gains an `owliver:` block tomorrow becomes `both` on its own.
* One logical capability, two surfaces — never two definitions.
*/
export function capabilityType(skill) {
const owliver = skill.facets?.includes('owliver');
const board = skill.facets?.includes('ui');
if (owliver && board) return 'both';
return board ? 'board' : 'owliver';
}
/** The type, in the words the detail panel uses. */
export const TYPE_LABEL = { owliver: 'Owliver', board: 'Board', both: 'Owliver + Board' };
/* ── Board skills ─────────────────────────────────────────────────────────
*
* The registry's other facet, read the same way. A Board skill draws a section
* on a KROW page; an Owliver skill teaches the assistant what it can be asked.
* One registry, one parser, two readings — exactly the split
* `WorkspaceSkills` already manages, surfaced here so the agent editor can show
* both under one Skills heading without either becoming a second system.
*
* **The relationship is genuinely different, and this module says so rather
* than flattening it.** An Owliver skill is carried by an agent: `agent.skills`
* decides what that agent may use, and `agentScopedDisabled` enforces it. A
* Board skill is not — `SkillSurface` renders sections from the page and the
* account's `disabledSkills`, and consults no agent at all. So a Board entry
* carries the surfaces it draws on and whether it is switched on, and never an
* "attached to this agent" flag, because there is nothing behind one.
*/
/** The sections a Board skill declares, flattened with the page each sits on. */
function sectionsOf(skill) {
return Object.entries(skill.ui || {}).flatMap(([page, config]) =>
(config.sections || []).map((section) => ({
id: section.id,
title: section.title || sectionTypeLabel(section.type),
type: section.type,
typeLabel: sectionTypeLabel(section.type),
page,
pageLabel: surfaceFor(page)?.label || page,
placement: section.placement,
placementLabel: placementLabel(section.placement),
source: section.source || null,
}))
);
}
/** One Board skill, as the agent editor needs to read it. */
export function boardEntry(skill) {
const group = groupFor(skill);
const sections = sectionsOf(skill);
return {
id: skill.id,
name: skill.name,
description: skill.description,
group,
groupLabel: groupLabel(group),
type: capabilityType(skill),
sections,
/** The pages this skill actually draws on, derived from its sections. */
surfaces: [...new Map(sections.map((s) => [s.page, { id: s.page, label: s.pageLabel }])).values()],
custom: Boolean(skill.custom),
status: skill.status,
};
}
/** Every Board skill in the registry, as entries. */
export function boardCatalog(customSkills = [], { pages = null } = {}) {
const source = pages
? compatibleSkills(pages, customSkills)
: aiAgentSkills(allSkills(customSkills));
return skillsWithFacet(source, 'ui')
.filter((s) => s.status === 'active')
.map(boardEntry)
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Does this Board skill draw on any surface the agent covers?
*
* The true relationship between an agent and a Board skill, and the only one
* there is: they can meet on a page. An agent that answers on Positions stands
* beside whatever Board sections Positions renders — it does not own them, and
* it cannot switch them on.
*/
export const boardMeetsAgent = (entry, pages = []) => {
if (!pages.length) return false;
const covered = new Set(pages);
return entry.surfaces.some((s) => covered.has(s.id));
};
/** Board entries, filtered by the same query the Owliver catalog uses. */
export function filterBoard(entries, { query = '' } = {}) {
const q = String(query || '').trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) => [
e.name, e.description, e.groupLabel, e.id,
...e.surfaces.map((s) => s.label),
...e.sections.map((s) => `${s.title} ${s.typeLabel} ${s.placementLabel}`),
].join(' ').toLowerCase().includes(q));
}
/**
* The groups this catalog actually contains, in a stable order.
*
* Derived rather than written down, so a filter can never offer a heading that
* matches nothing — and a definition declaring a category nobody anticipated
* appears under it instead of vanishing into "everything else".
*/
export function catalogGroups(entries = []) {
const counts = new Map();
for (const entry of entries) {
counts.set(entry.group, (counts.get(entry.group) || 0) + 1);
}
const known = SKILL_GROUPS
.filter((g) => counts.has(g.id))
.map((g) => ({ ...g, count: counts.get(g.id) }));
const extra = [...counts.keys()]
.filter((id) => !GROUP_BY_ID.has(id))
.sort()
.map((id) => ({ id, label: groupLabel(id), blurb: '', count: counts.get(id) }));
return [...known, ...extra];
}
/**
* Does this entry match what was typed?
*
* Name, description, group, the surfaces it answers on, the capabilities it
* offers and its id — so "attendance", "Control Center", "table" and
* "anomaly-detection" all find something, and a reader who knows the domain
* rather than the catalog can still search it.
*/
export function matchesQuery(entry, query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return true;
const haystack = [
entry.name,
entry.description,
entry.groupLabel,
entry.id,
...entry.surfaces.map((s) => s.label),
...entry.capabilities.shapes,
...entry.capabilities.described,
...entry.tools.map((t) => t.label),
].join(' ').toLowerCase();
return haystack.includes(q);
}
/**
* The catalog, filtered.
*
* One function so the count shown beside a filter and the cards under it are
* the same reading — two filters written separately is how a heading comes to
* say "6 skills" above four cards.
*/
export function filterCatalog(entries, { query = '', group = 'all' } = {}) {
return entries
.filter((e) => group === 'all' || e.group === group)
.filter((e) => matchesQuery(e, query));
}

View File

@@ -735,7 +735,7 @@ export function contextIdsForSkill(skill) {
* Nothing downstream may test a page name against a skill id. The moment a page
* asks "is this Server Training?" the attachment has stopped being data.
*/
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind = undefined } = {}) {
if (!pageId) return [];
const wanted = canonicalPage(pageId) || pageId;
return allSkills(customSources).filter(

View File

@@ -250,6 +250,22 @@ for (const surface of SKILL_SURFACES) {
}
/** Every name a definition may use for a surface, for error messages. */
/**
* The surfaces that are product *domains* rather than configuration screens.
*
* Read off `placements`, which is already the honest distinction: a domain
* surface offers places for a section to sit because it holds workforce
* records, and a configuration screen offers none because it holds none. That
* is why Settings, Workspace and the two editors declare `placements: []`.
*
* The agent editor uses this so its scope picker offers the pages an agent
* could sensibly answer *about*, instead of every route the vocabulary happens
* to name. Derived, so a surface added tomorrow classifies itself.
*/
export const DOMAIN_SURFACES = SKILL_SURFACES
.filter((s) => s.placements.length > 0)
.map((s) => s.id);
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
/**