257 lines
9.6 KiB
JavaScript
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;
|
|
}
|