UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

451 lines (450 loc) • 20.7 kB
/** * Built-in skill tools. * * Two factories, mirroring the two disclosure levels: * * createSkillTools — instance tools registered once at construction: * list_skills (lightweight catalog) plus the gated skill_create / * skill_update / skill_delete mutation tools. * * createSkillCallTools — per-call tools injected into options.tools by * prepareGenerate/prepareStream (the RAG-tool pattern): use_skill, whose * description embeds the `<available_skills>` listing so the model * decides from context when a skill applies (no mandatory pre-flight * call), and read_skill_resource for on-demand auxiliary files. The * sessionId rides in by closure, so activations pin to the session * without runtime tool-context plumbing. * * The manager is resolved lazily via a resolver callback so tools can be * registered at construction time while the store initializes on first * use (mirrors how retrieve_context resolves conversationMemory lazily). */ import { trace } from "@opentelemetry/api"; import { z } from "zod"; import { logger } from "../utils/logger.js"; import { tool } from "../utils/tool.js"; import { isSkillVisibleInScope } from "./skillMatcher.js"; const NOT_CONFIGURED = { success: false, error: "Skills are not available — the skills store failed to initialize. " + "Answer from general knowledge.", }; /** Response payload for an activated skill — full instructions, never truncated. */ function toActivationPayload(skill) { return { name: skill.name, ...(skill.displayName ? { displayName: skill.displayName } : {}), version: skill.version ?? 1, instructions: skill.instructions, ...(skill.resources && skill.resources.length > 0 ? { resources: skill.resources.map((r) => r.path), resourcesHint: "Auxiliary files — read one with read_skill_resource only when the task needs it.", } : {}), note: "Follow these instructions for the current task. The skill stays loaded " + "for this conversation — do not invoke use_skill for it again.", }; } /** Build the use_skill description for the call's discovery mode. */ function useSkillDescription(context) { const base = "Load a team-defined skill (SOP, playbook, workflow) by name to get its full instructions. " + "When the user's task matches a skill, invoke this tool, then follow the loaded instructions exactly. " + "Do NOT invoke it for a skill that is already loaded in this conversation, and do not " + "invoke it at all when no skill matches the task."; if (context.discovery === "tool" && context.listing) { return `${base}\n\nSkills available in this conversation:\n${context.listing}`; } if (context.discovery === "system-prompt") { return `${base} The available skills are listed under "## Available Skills" in your instructions.`; } return `${base} Discover available skills with list_skills.`; } /** * Per-call skill tools: use_skill + read_skill_resource. Injected into * options.tools for one generate()/stream() call; same-name entries shadow * any registered tool, so the description always reflects this call's * listing and scope. */ export function createSkillCallTools(resolveManager, context) { /** * Rebuild activation state from stored history before every dedup * check. use_skill is rare, so one history read per attempt is cheap — * and it keeps dedup truthful across restarts, other instances sharing * the memory backend, and pins whose persistence failed. Fail-open: a * hydration error only risks a duplicate pin, never a lost activation. */ const ensureHydrated = async (manager) => { const { sessionId, getStoredMessages } = context; if (!sessionId) { return; } try { const stored = getStoredMessages ? await getStoredMessages(sessionId) : []; manager.sessions.hydrate(sessionId, stored); } catch (error) { logger.warn("[SkillTools] Session hydration failed — continuing", { sessionId, error: error instanceof Error ? error.message : String(error), }); } }; return { use_skill: tool({ description: useSkillDescription(context), inputSchema: z.object({ name: z .string() .min(1) .describe('Exact skill name from the available skills listing, e.g. "refund_dispute_escalation".'), }), execute: async (args) => { const manager = resolveManager(); if (!manager) { return NOT_CONFIGURED; } try { const skill = await manager.get(args.name); if (!skill || !isSkillVisibleInScope(skill, context.scopeId)) { return { success: false, error: `No skill named "${args.name}" is available. Use the exact name ` + "from the available skills listing (or list_skills).", }; } if (context.sessionId && context.sessionPersistence) { await ensureHydrated(manager); if (manager.sessions.isActive(context.sessionId, skill.id, skill.name)) { // Report the version the session actually follows — a // mid-session update must not masquerade as loaded. const activation = manager.sessions.getActivation(context.sessionId, skill.id, skill.name); return { success: true, data: { status: "already_loaded", name: skill.name, version: activation?.version ?? skill.version ?? 1, message: "This skill is already loaded in the conversation — follow " + "the instructions from when it was loaded.", }, }; } manager.sessions.recordActivation(context.sessionId, skill); } const pinned = Boolean(context.sessionId && context.sessionPersistence); trace.getActiveSpan()?.setAttributes({ "skill.name": skill.name, "skill.version": skill.version ?? 1, "skill.instructions_chars": skill.instructions.length, "skill.pinned": pinned, }); logger.debug("[SkillTools] Skill activated", { skill: skill.name, version: skill.version ?? 1, sessionId: context.sessionId, pinned, }); return { success: true, data: toActivationPayload(skill) }; } catch (error) { logger.warn("[SkillTools] use_skill failed", { skill: args.name, error: error instanceof Error ? error.message : String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), read_skill_resource: tool({ description: "Read an auxiliary file bundled with a loaded skill (paths appear in the " + "skill's `resources` list or are referenced from its instructions). Load the " + "skill with use_skill first. Only read files the current task actually needs — " + "each resource costs context.", inputSchema: z.object({ skill: z.string().min(1).describe("Name of the loaded skill."), path: z .string() .min(1) .describe('Resource path relative to the skill, e.g. "references/forms.md".'), }), execute: async (args) => { const manager = resolveManager(); if (!manager) { return NOT_CONFIGURED; } try { const skill = await manager.get(args.skill); if (!skill || !isSkillVisibleInScope(skill, context.scopeId)) { return { success: false, error: `No skill named "${args.skill}" is available.`, }; } if (context.sessionId && context.sessionPersistence) { await ensureHydrated(manager); if (!manager.sessions.isActive(context.sessionId, skill.id, skill.name)) { return { success: false, error: `Skill "${skill.name}" is not loaded — call use_skill first.`, }; } } const content = await manager.getResource(skill.id, args.path); if (content === null) { return { success: false, error: `Resource "${args.path}" not found on skill "${skill.name}". ` + "Valid paths are listed in the skill's `resources`.", }; } return { success: true, data: { skill: skill.name, path: args.path, content }, }; } catch (error) { logger.warn("[SkillTools] read_skill_resource failed", { skill: args.skill, path: args.path, error: error instanceof Error ? error.message : String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } }, }), }; } /** * Instance skill tools bound to a lazily-resolved manager: list_skills * plus the gated mutation tools. Returns Vercel AI SDK tool() objects * (description + Zod inputSchema + execute) keyed by tool name. */ export function createSkillTools(resolveManager, options) { const tools = { list_skills: tool({ description: "Returns a lightweight list of all available skills — name, display name, description, and " + "tags only. No instructions are returned, keeping context cost minimal. " + 'Use this only when a user explicitly asks "what skills do you have?" or "what can you help ' + 'me with?". To load a skill for a task, use use_skill instead.', inputSchema: z.object({ scopeId: z .string() .optional() .describe("Scope identifier to include scoped skills alongside global ones. Usually omit."), }), execute: async (args) => { const manager = resolveManager(); if (!manager) { return { ...NOT_CONFIGURED, data: { skills: [] } }; } try { const items = await manager.list(args.scopeId); return { success: true, data: { skills: items.map((item) => ({ name: item.name, ...(item.displayName ? { displayName: item.displayName } : {}), description: item.description, tags: item.tags ?? [], scope: item.scope ?? "global", })), count: items.length, }, }; } catch (error) { logger.warn("[SkillTools] list_skills failed", { error: error instanceof Error ? error.message : String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), data: { skills: [] }, }; } }, }), }; if (options?.allowMutations) { Object.assign(tools, createSkillMutationTools(resolveManager)); } return tools; } /** Shared executor for the three mutation tools. */ async function runMutation(resolveManager, action) { const manager = resolveManager(); if (!manager) { return NOT_CONFIGURED; } try { const result = await manager.requestMutation(action); if (result.decision.outcome === "rejected") { return { success: false, error: `Skill ${action.type} was rejected${result.decision.reason ? `: ${result.decision.reason}` : "."}`, }; } if (result.decision.outcome === "pending") { return { success: true, data: { status: "pending_approval", ...(result.decision.reference ? { reference: result.decision.reference } : {}), message: `Skill ${action.type} was submitted for approval. It takes effect once an approver accepts it.`, }, }; } return { success: true, data: { status: "applied", ...(result.skill ? { skillId: result.skill.id, name: result.skill.name, version: result.skill.version, skillStatus: result.skill.status, } : {}), }, }; } catch (error) { logger.warn(`[SkillTools] skill_${action.type} failed`, { error: error instanceof Error ? error.message : String(error), }); return { success: false, error: error instanceof Error ? error.message : String(error), }; } } function createSkillMutationTools(resolveManager) { const scopeFields = { scope: z .enum(["global", "scoped"]) .optional() .describe('"global" = available everywhere; "scoped" = only for specific scope ids.'), scopeIds: z .array(z.string()) .optional() .describe('Required when scope="scoped": the scope ids (channel/team/tenant) the skill applies to.'), }; return { skill_create: tool({ description: "Propose a NEW skill (SOP/playbook). Depending on host configuration the skill is either " + "applied immediately or queued for human approval. " + "INVOKE ONLY WHEN the user explicitly asks to create a new skill and has supplied the name, " + "description, and full instructions text. DO NOT INVENT CONTENT — the instructions must come " + "from the user verbatim or nearly so. If the user has not given the full text, ask. " + "Do not use this to modify an existing skill (use skill_update).", inputSchema: z.object({ name: z .string() .min(1) .describe('Short machine-friendly skill name (snake_case recommended), unique. E.g. "refund_dispute_escalation".'), displayName: z .string() .optional() .describe('Human-readable display name. E.g. "Refund Dispute Escalation".'), description: z .string() .min(1) .describe("One or two sentences explaining when this skill applies. Used for matching."), instructions: z .string() .min(1) .describe("The full step-by-step instructions to follow when this skill matches. " + "Must come from the user — do not invent steps."), tags: z .array(z.string()) .optional() .describe('Domain tags for filtering (e.g. ["payments", "escalation"]).'), ...scopeFields, requestedBy: z .string() .optional() .describe("Identifier of the requesting user, when known."), }), execute: async (args) => { if (args.scope === "scoped" && (args.scopeIds ?? []).length === 0) { return { success: false, error: '"scopeIds" must contain at least one scope id when scope="scoped".', }; } const { requestedBy, ...skill } = args; return runMutation(resolveManager, { type: "create", skill, ...(requestedBy ? { requestedBy } : {}), }); }, }), skill_update: tool({ description: "Propose an update to an EXISTING skill. Only the provided fields change; the version is " + "bumped. Depending on host configuration the change is applied immediately or queued for " + "human approval. Look the skill up with list_skills first to get its exact name. " + "Do not paraphrase or expand the user's instructions.", inputSchema: z.object({ skillId: z .string() .min(1) .describe("Id (or exact unique name) of the skill to update."), displayName: z.string().optional(), description: z.string().optional(), instructions: z .string() .optional() .describe("Replacement instructions text, verbatim from the user."), tags: z.array(z.string()).optional(), ...scopeFields, requestedBy: z.string().optional(), }), execute: async (args) => { const { skillId, requestedBy, ...patch } = args; if (Object.values(patch).every((v) => v === undefined)) { return { success: false, error: "Provide at least one field to update.", }; } return runMutation(resolveManager, { type: "update", skillId, patch, ...(requestedBy ? { requestedBy } : {}), }); }, }), skill_delete: tool({ description: "Propose deleting (deprecating) an EXISTING skill. The skill stops matching but stays in " + "storage for audit. Depending on host configuration this is applied immediately or queued " + "for human approval. INVOKE ONLY WHEN the user explicitly asks to delete a skill.", inputSchema: z.object({ skillId: z .string() .min(1) .describe("Id (or exact unique name) of the skill to delete."), requestedBy: z.string().optional(), }), execute: async (args) => runMutation(resolveManager, { type: "delete", skillId: args.skillId, ...(args.requestedBy ? { requestedBy: args.requestedBy } : {}), }), }), }; }