Files
krow_talent_app/src/lib/skills/tools.js
Aravind 6249e00a3a
Some checks failed
CI / check (push) Failing after 4m58s
candidates and board ui agent issue
2026-09-05 10:46:06 +05:30

257 lines
9.6 KiB
JavaScript

import { skillsForContext } from './registry';
import { ACTION_NAMES } from './actions';
/**
* Tools, described.
*
* Boundary 6. This adds **no capability**: every tool here is an action
* `actions.js` already performs, and `runAction` remains the only thing that
* performs them. What was missing was a description — what a tool does, what it
* needs, whether it changes anything, and whether a person should be asked
* first. Without that, a caller deciding whether to confirm an action had to
* hard-code a list of which ones were dangerous.
*
* Describing them separately is also what makes them exposable later. A future
* MCP surface publishes these descriptors and calls the same `runAction`;
* nothing in the business logic moves. That is the whole reason this file is a
* table rather than a set of wrappers.
*
* **The page boundary is inherited, not restated.** `toolsForContext` reads the
* skills that are reachable on the current page for the current agent, and
* collects what *they* declare. A tool is therefore reachable only when a skill
* on this page declares it and the agent carries that skill — so a tool can
* never reach data the page was not already offering, and selecting a different
* agent can only ever remove tools from that list.
*/
/**
* What each action is, in the terms a person confirming it would need.
*
* `requiresApproval` is a property of the action, never of the caller: an
* action that writes a record needs a person to agree whichever surface asked
* for it. `readOnly` actions move the reader somewhere and change nothing.
*/
export const TOOLS = [
{
name: 'create_position',
label: 'Create position',
summary: 'Writes a new job posting from a draft collected in conversation.',
params: ['draft', 'status'],
readOnly: false,
mutates: 'JobPosting',
/* Writes a record other people will act on. Always confirmed. */
requiresApproval: true,
},
{
name: 'create_employee_role',
label: 'Create employee role',
summary: "Records what a worker declares they do — role, experience, desired pay and availability — from a conversation.",
params: ['draft', 'status'],
readOnly: false,
mutates: 'EmployeeRole',
/* Writes a record ABOUT SOMEBODY ELSE, which is the stronger case for a
confirmation rather than the weaker one: the person it names is not the
person approving it. */
requiresApproval: true,
},
{
name: 'open_create_skill_training',
label: 'Open Add Skill Training',
summary: 'Opens the Forge authoring form, prefilled from the conversation.',
params: ['prefill'],
readOnly: true,
mutates: null,
/* Opens a form. Nothing is written until the person submits it, so asking
twice would be asking about the same decision twice. */
requiresApproval: false,
},
{
name: 'open_create_training',
label: 'Open Add Training',
summary: 'Opens the training authoring form for a course.',
params: ['prefill', 'courseId'],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
name: 'navigate_to_positions',
label: 'Open Positions',
summary: 'Takes the reader to the Positions page.',
params: [],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
name: 'navigate_to_candidates',
label: 'Open Candidates',
summary: 'Takes the reader to the Candidates page.',
params: [],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
name: 'navigate_to_forge',
label: 'Open KROW Forge',
summary: 'Takes the reader to the Forge library.',
params: [],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
name: 'navigate_to_analytics',
label: 'Open Analytics',
summary: 'Takes the reader to the Analytics page.',
params: [],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
/**
* Open whichever Krow page a reading belongs to.
*
* The general form of the four fixed `navigate_to_*` actions above, which
* each name one destination. This one takes a page key and resolves it
* through the same placement table, so a skill can send the reader to the
* page its analysis was about without a new action per destination.
*
* Still bounded: `routeForPageKey` only knows addresses the product has,
* and an unknown key resolves to nothing rather than to a guess.
*/
name: 'open_related_page',
label: 'Open the related page',
summary: 'Takes the reader to the Krow page a reading came from.',
params: ['page'],
readOnly: true,
mutates: null,
requiresApproval: false,
},
];
const BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
/** One tool's description, or null. */
export const describeTool = (name) => BY_NAME.get(name) || null;
export const TOOL_NAMES = TOOLS.map((tool) => tool.name);
/** Whether an action changes something a person should agree to first. */
export const toolRequiresApproval = (name) => Boolean(BY_NAME.get(name)?.requiresApproval);
/**
* Every action name a handler exists for but nothing describes.
*
* A handler with no descriptor is invisible to anything reasoning about tools —
* including whatever decides to ask for confirmation — so it would run
* unannounced. Asserted in the checks rather than left to review.
*/
export const undescribedActions = () =>
ACTION_NAMES.filter((name) => !BY_NAME.has(name));
/**
* The tools reachable on this page, for this agent.
*
* Derived from the scoped skill list, so the page boundary is inherited rather
* than re-implemented: a skill the page does not carry contributes no tools, and
* a skill the agent does not carry has already been removed from that list by
* `agentScopedDisabled`.
*
* `disabled` is expected to already carry the agent's scoping. Passing the raw
* account list yields the page's full tool set, which is what an unscoped
* caller should get.
*/
export function toolsForContext(contextId, disabled = [], customSkills = []) {
const reachable = skillsForContext(contextId, disabled, customSkills);
const names = new Set();
for (const skill of reachable) {
for (const action of skill.actions || []) names.add(action);
}
return [...names]
.map((name) => describeTool(name))
.filter(Boolean)
.sort((a, b) => a.label.localeCompare(b.label));
}
/**
* Whether this tool may run here.
*
* The check a caller makes before offering a control. Deliberately takes the
* resolved list rather than recomputing it, so a caller cannot accidentally ask
* the question against a wider scope than the one it rendered from.
*/
export const toolAllowed = (name, allowed = []) =>
allowed.some((tool) => tool.name === name);
/* ── Typed action intent ────────────────────────────────────────────────── */
/**
* How much has to be typed before a partial word counts as an intent.
*
* Three, and the number is the whole point of the rule. One or two characters
* cannot say what somebody meant — "wh" is the start of four unrelated
* questions — and offering anything on them is how the composer ended up
* interrupting every reader who had already decided what to ask. Three is the
* shortest prefix of a real verb, and it still matches nothing unless it is
* genuinely the beginning of an action this page can perform.
*/
const ACTION_INTENT_MIN = 3;
/**
* A phrase reduced to what it means, so matching is about intent not typing.
*
* Case, surrounding space and an article are not differences: "Create",
* "create" and "create a" are all the beginning of the same request. Kept local
* and deliberately tiny — it exists to compare two short phrases, and anything
* cleverer would start deciding what a reader meant.
*/
const canon = (value) => String(value || '')
.toLowerCase()
.trim()
.split(/\s+/)
.filter((w) => w && !/^(?:a|an|the)$/.test(w))
.join(' ');
/**
* The actions this page can perform that the typed text is starting to name.
*
* Derived, never listed. The phrases come from the skills themselves — a
* definition's `prompt:` is the sentence its author wrote for exactly this
* purpose — and the candidate set is whatever `skillsForContext` already
* resolved, which has the page filter and the agent's scoping applied to it.
* So a skill added tomorrow is offered here without this file changing, a skill
* switched off in Settings is not offered at all, and there is no second list
* of action names to keep in step with the first.
*
* Only skills that DECLARE an action are eligible. A reading skill has nothing
* to autocomplete towards: "which positions are in draft" is a question, and
* offering it while somebody types is the generic-catalogue behaviour this
* replaced.
*
* Matched on `canon`, so "Create" reaches "Create a position" — the article and
* the case are not differences — and "create position" reaches it too. A prefix
* rather than a substring: typing the middle of a phrase is not evidence of
* intent, and substring matching is what made every keystroke produce chips.
*/
export function actionSuggestions(typed, skills = []) {
const query = canon(typed);
if (query.length < ACTION_INTENT_MIN) return [];
const seen = new Set();
const out = [];
for (const skill of skills) {
if (!skill?.prompt || !skill.actions?.length) continue;
const phrase = canon(skill.prompt);
if (!phrase.startsWith(query)) continue;
if (seen.has(phrase)) continue;
seen.add(phrase);
out.push({ label: skill.prompt, prompt: skill.prompt });
}
return out;
}