Some checks failed
CI / check (push) Has been cancelled
The key had two incompatible meanings running at once. CLAUDE.md §3 defines subagents as "keys of other specs this may DELEGATE to" and §6 as a tool call from the parent's perspective — the subagent runs its own turn, as the same caller, out of the parent's budget, and returns an answer. The backend implements exactly that. This side did something else: agentSkillIds folded one level of subagent skills into the parent's carried set, so a parent silently gained everything its subagents carried, and the UI described it that way — "other agents whose skills this one may also use", "borrowing them cannot reach data this page does not hold". Both are defensible readings. Only one is the specification, and running both meant krow-workforce-agent carried eight delegation tools AND the flattened skills of those same eight agents — able to answer a question directly or to ask an agent that had already lent it the means to answer. Two ways to do one thing, differing in cost and in what the trajectory records. So an agent carries what it declares. Reaching another agent is delegation, which the runtime does with its own budget and its own trajectory. The blast radius was one check, which is the useful part of the answer: only "a subagent cycle terminates" depended on the folding, because that traversal was the only thing that could loop. Nothing walks subagents here now, so the cycle question moved to where it belongs — refused at publish by definition.FindSubagentCycle, bounded at run time by the depth cap. The replacement checks assert the new meaning rather than deleting the old ones, because the previous behaviour reads as perfectly reasonable and will be reinvented otherwise. The `agents` parameter stays on agentSkillIds and is no longer read. Removing it is a wider edit for no behavioural gain, and agentScopedDisabledWith exists precisely to pass it. 924/924 checks pass; production build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
408 lines
18 KiB
JavaScript
408 lines
18 KiB
JavaScript
import { DOMAIN_SURFACES, 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.
|
|
*
|
|
* Its OWN skills, and only those. `subagents` used to be folded in here — one
|
|
* level deep, so a parent silently carried everything its subagents carried —
|
|
* and that was a second, incompatible meaning for the same key.
|
|
*
|
|
* CLAUDE.md §3 defines `subagents` as "keys of other specs this may DELEGATE
|
|
* to", and §6 as a tool call from the parent's perspective: the subagent runs
|
|
* its own turn, as the same caller, sharing the parent's budget, and returns
|
|
* an answer. The backend implements that. Borrowing the skills instead meant
|
|
* the two halves of the product disagreed about what an agent was allowed to
|
|
* do, and krow-workforce-agent carried eight delegation tools AND the
|
|
* flattened skills of those same eight agents — asking twice for one answer.
|
|
*
|
|
* So this returns what the spec says it carries. Reaching another agent is
|
|
* delegation, which is the runtime's job and has its own budget and its own
|
|
* trajectory.
|
|
*
|
|
* `agents` is still accepted so every call site keeps working; it is no longer
|
|
* read. Removing the parameter would be a wider edit for no behavioural gain,
|
|
* and agentScopedDisabledWith exists precisely to pass it.
|
|
*/
|
|
export function agentSkillIds(agent) {
|
|
if (!agent) return [];
|
|
return [...new Set(agent.skills || [])];
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* Whether an agent is a *general* one: it covers every domain surface.
|
|
*
|
|
* Derived from the definition rather than matched against an id. This used to
|
|
* be `FALLBACK_AGENT_ID = 'krow-workforce-agent'` — a literal agent key that
|
|
* two functions below branched on, which is precisely the thing agent specs
|
|
* being data is supposed to make impossible. With a key in the runtime,
|
|
* renaming the general agent silently demotes it, deleting it leaves two dead
|
|
* branches, and a workspace can never write a second general agent because
|
|
* only one id is privileged.
|
|
*
|
|
* Reading `pages` instead makes it a fact about the spec: an agent listing
|
|
* every surface that holds workforce records is an agent with no speciality,
|
|
* which is exactly what makes it the sensible fallback. A general agent may
|
|
* list *more* than the domain surfaces — the workforce agent also covers the
|
|
* agent workspace — so this is a subset test, never an equality one.
|
|
*/
|
|
export function isGeneralAgent(agent) {
|
|
if (!agent?.pages?.length) return false;
|
|
const covered = new Set(agent.pages);
|
|
return DOMAIN_SURFACES.every((id) => covered.has(id));
|
|
}
|
|
|
|
/**
|
|
* The agent written *for* this page, if there is one.
|
|
*
|
|
* General agents are deliberately excluded. One 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) => !isGeneralAgent(a)) || null;
|
|
}
|
|
|
|
/**
|
|
* The general agent, when one can answer here.
|
|
*
|
|
* `agentsForContext` has already narrowed to published agents covering this
|
|
* page and sorted them most-specific-first, so general agents sit at the end
|
|
* and the *last* of them is the broadest. Taking that one is identical to the
|
|
* old behaviour while exactly one general agent exists, and is a stated choice
|
|
* rather than an arbitrary one once a workspace has written a second.
|
|
*
|
|
* Falls through to whichever published agent covers the page when no general
|
|
* one does — a page must never be left without an agent because of how the
|
|
* registry happens to be configured.
|
|
*/
|
|
export function fallbackAgentForContext(agents = [], contextId) {
|
|
const covering = agentsForContext(agents, contextId);
|
|
const general = covering.filter(isGeneralAgent);
|
|
return general[general.length - 1] || covering[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,
|
|
};
|
|
}
|