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, }; }