update agents skill design
This commit is contained in:
177
src/lib/skills/tools.js
Normal file
177
src/lib/skills/tools.js
Normal file
@@ -0,0 +1,177 @@
|
||||
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: '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);
|
||||
Reference in New Issue
Block a user