update agents skill design

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

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

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