UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

292 lines • 13.9 kB
/** * Skills: an authored procedure the model pulls in when a task calls for it, instead of every * instruction living in the system prompt. * * WHAT A SKILL IS NOT: an agent. An agent is WHO is answering — its persona, its tools, its history * window, its output schema. A skill is HOW one particular task is done, and any agent may pull one * in. That is why a skill carries no model, no tool list and no schema: the moment it did, the two * would be the same thing wearing different names, and a consumer would have to choose between them * for reasons nobody could state. * * WHAT IT COSTS THE PROMPT: one line per skill. The catalog block below carries names, scopes and * descriptions only; a BODY reaches the model as a tool result, on the transcript, where the * `HistoryWindow` ceiling already governs it. So skills are not a fourth thing competing for the * system block with the agent's own prompt, its persona and injected retrieval — see * `AgentLoopDeps.skills` for how they compose. */ /** The scope token every deployment has: skills nobody narrowed. */ export const GLOBAL_SCOPE = 'global'; /** The token for one actor's own skills. */ export function actorScope(actor) { return `actor:${actor.id}`; } /** The token for a tenant's skills — `Actor.tenantRef` is the only tenant key this library knows. */ export function tenantScope(tenantRef) { return `tenant:${tenantRef}`; } /** * The scopes derivable from an {@link Actor} alone — the common case, so a consumer wiring skills * for the first time supplies no resolver at all: the actor's own, their tenant's (when they have * one), and the deployment's. A host adding an axis of its own replaces this rather than extending * it, since the ORDER is the precedence and only the host knows where its axis belongs. */ export const defaultScopeResolver = { resolve: ({ actor }) => [ actorScope(actor), ...(actor.tenantRef !== undefined ? [tenantScope(actor.tenantRef)] : []), GLOBAL_SCOPE, ], }; /** A source of skills that is a fixed list — module config, or a host's own constant. */ export function staticSkillProvider(skills) { return { list: ({ scopes }) => skills.filter((skill) => scopes.includes(skill.scope)), load: ({ name, scope }) => skills.find((skill) => skill.name === name && skill.scope === scope)?.body ?? null, }; } /** * Read several sources as one. The order is the tie-break and nothing else: precedence between * skills is by SCOPE, so two sources only ever compete when they publish the same name at the same * scope, and then the earlier source wins. Wire the host's own provider first — a row it can edit is * a better answer than a body that needs a deploy to change. */ export function compositeSkillProvider(providers) { return { list: async ({ scopes, ctx }) => { const seen = new Set(); const merged = []; for (const provider of providers) { for (const summary of await provider.list({ scopes, ctx })) { const key = `${summary.scope}${summary.name}`; if (!seen.has(key)) { seen.add(key); merged.push(summary); } } } return merged; }, load: async ({ name, scope, ctx }) => { for (const provider of providers) { const body = await provider.load({ name, scope, ctx }); if (body !== null) { return body; } } return null; }, }; } /** * How many skills a catalog offers before it starts leaving some out. A ceiling on the SYSTEM * PROMPT's share, not on how many a deployment may have: the block costs one line each, and a * hundred lines of "here is something you could load" crowds out the agent's own instructions while * making the choice harder rather than easier. */ export const DEFAULT_MAX_SKILLS = 20; /** * Resolve a provider's skills against an ordered scope list: most specific wins, and the loser's * scope is recorded rather than discarded. * * Pure, and separately exported, because two callers must reach the identical answer — the loop, so * the model is offered it, and the listing endpoint, so a user is. A second implementation of "which * skills apply" is a second answer, and the two diverge the first time either is edited. */ export function resolveSkillCatalog(summaries, scopes, maxSkills = DEFAULT_MAX_SKILLS) { const rank = new Map(scopes.map((scope, index) => [scope, index])); const byName = new Map(); for (const summary of summaries) { // A scope the resolver did not return is one this actor has no claim on, whatever the provider // thinks. Dropping it here is what makes over-returning a performance mistake instead of a leak. if (!rank.has(summary.scope)) { continue; } const existing = byName.get(summary.name); if (existing === undefined) { byName.set(summary.name, [summary]); } else { existing.push(summary); } } const resolved = []; for (const candidates of byName.values()) { const ordered = [...candidates].sort((a, b) => (rank.get(a.scope) ?? 0) - (rank.get(b.scope) ?? 0)); const winner = ordered[0]; if (winner === undefined) { continue; } const shadows = ordered.slice(1).map((candidate) => candidate.scope); resolved.push({ name: winner.name, description: winner.description, scope: winner.scope, ...(shadows.length > 0 ? { shadows } : {}), }); } // Most specific first, then alphabetical: an order a reader can predict, and one where trimming // the tail to `maxSkills` drops the WIDEST skills — an org default is the one a deployment can // best afford to lose, and the actor's own is the one it can least. resolved.sort((a, b) => { const byScope = (rank.get(a.scope) ?? 0) - (rank.get(b.scope) ?? 0); return byScope !== 0 ? byScope : compareStrings(a.name, b.name); }); return { entries: resolved.slice(0, maxSkills), omitted: Math.max(0, resolved.length - maxSkills), }; } function compareStrings(a, b) { return a < b ? -1 : a > b ? 1 : 0; } /** Resolve the scopes and the catalog for one turn. Called INSIDE the loop's `skills:catalog` step. */ export async function offerSkills(config, ctx) { const scopes = await (config.scopes ?? defaultScopeResolver).resolve(ctx); const summaries = await config.provider.list({ scopes, ctx }); return { scopes, ...resolveSkillCatalog(summaries, scopes, config.maxSkills) }; } /** The reserved name of the built-in skill-loading tool. */ export const SKILL_TOOL_NAME = 'skill'; /** * The catalog as the model reads it. One line per skill — name, scope, description, and what it * overrides — because the entire claim of progressive disclosure is that choosing what to read costs * far less than reading everything. */ export function buildSkillsBlock(entries) { const lines = entries.map((entry) => { const shadows = entry.shadows === undefined ? '' : ` (overrides the one from ${entry.shadows.join(', ')})`; return `- ${entry.name} [${entry.scope}] — ${entry.description}${shadows}`; }); return `<skills> Procedures available to you. Each line is a name, the scope it came from, and what it covers — not the procedure itself. Call the \`${SKILL_TOOL_NAME}\` tool with a name from this list to read one BEFORE doing the task it covers, and follow what it says; never guess its contents from the description. A skill from a narrower scope overrides a same-named one from a wider scope. When you follow one that overrides another, say which scope it came from, so the user knows their setting differs from the wider default. ${lines.join('\n')} </skills>`; } const SKILL_JSON_SCHEMA = { type: 'object', additionalProperties: false, required: ['name'], properties: { name: { type: 'string', description: 'The name of a skill from the <skills> list, exactly as written there.', }, }, }; function issue(path, message) { return { message, path }; } /** * The `skill` tool's input schema. Hand-written for the same reason `askInputSchema` is: this * package depends on no validator, and the schema has to publish a JSON Schema a provider can * constrain generation against. * * The available names are deliberately NOT an enum here. A per-turn enum would make the tool's * definition depend on the catalog, and a tool list re-derived by whichever process serves a turn is * exactly the process-local lookup this design keeps off a turn's decisions. The names live in the * prompt, where the journal holds them; a name that is not in the catalog comes back as an ordinary * tool failure listing the ones that are. */ export const skillInputSchema = { '~standard': { version: 1, vendor: 'adonis-agora-agent', validate: (value) => { if (typeof value !== 'object' || value === null) { return { issues: [issue([], 'must be an object')] }; } const candidate = value; if (typeof candidate.name !== 'string' || candidate.name.length === 0) { return { issues: [issue(['name'], 'must be a non-empty string')] }; } return { value: { name: candidate.name } }; }, jsonSchema: { input: () => SKILL_JSON_SCHEMA }, }, }; export const SKILL_TOOL_DESCRIPTION = 'Read one of the procedures listed in <skills>. Call it before doing a task a listed skill covers, and follow what it returns. It performs nothing and changes nothing — it only gives you instructions you do not yet have.'; /** * The `skill` tool as the model sees it. NOT a `ToolSpec` and never registered, exactly like `ask`: * it has no handler, because the loop serves it from the catalog the journal holds. Keeping it out * of the `ToolRegistry` is also what keeps its kind off a process-local lookup — see * `claimToolCall`. */ export function skillToolDefinition() { return { name: SKILL_TOOL_NAME, kind: 'skill', description: SKILL_TOOL_DESCRIPTION, inputSchema: skillInputSchema, }; } /** * Serve one `skill` call against the catalog THIS TURN was offered. * * The catalog is the authorization boundary, not just a menu: a name the turn was never offered is * refused here, so a model that invents one (or repeats one it saw in another actor's thread) cannot * reach a body through a provider that would happily serve it. Because the catalog came out of the * `skills:catalog` checkpoint, that boundary is the one the journal records rather than the one this * process's provider currently believes in. */ export async function loadSkill(args) { const { config, offer, name, ctx } = args; const entry = offer.entries.find((candidate) => candidate.name === name); if (entry === undefined) { const available = offer.entries.map((candidate) => candidate.name).join(', '); return { ok: false, error: available.length === 0 ? `No skill named "${name}" is available to you, and no others are either.` : `No skill named "${name}" is available to you. Available: ${available}.`, }; } const body = await config.provider.load({ name: entry.name, scope: entry.scope, ctx }); if (body === null) { return { ok: false, error: `Skill "${name}" is listed but its body could not be read.` }; } return { ok: true, skill: { name: entry.name, description: entry.description, scope: entry.scope, body }, ...(entry.shadows !== undefined ? { shadows: entry.shadows } : {}), }; } /** * May this author publish a skill at this scope? * * A skill's body is instructions the model follows, which makes writing one at a scope an edit to * everyone in that scope's system prompt. The rules follow from that, and only the third is * interesting: * * 1. You may only write into a scope you are yourself in. Writing into a scope you are not in is * authoring for people you have no relationship with. * 2. Your OWN scope is yours. A per-actor skill affects exactly one prompt — the author's. * 3. NOTHING BUT A HUMAN MAY WRITE ABOVE ITS OWN SCOPE, whatever `elevated` says. An agent that can * write a `tenant:` skill is an agent whose prompt anyone in the tenant can edit by talking to * it: the user asks for something, the turn writes the instruction, and every later turn for * every other user follows it. That is prompt injection with persistence, and no elevation a host * could grant makes it a different shape. An agent that has genuinely learned something worth * sharing proposes it; a person publishes it. * 4. A human writing above their own scope needs the host to say so (`elevated`). */ export function skillWriteVerdict(request) { const { scope, scopes, author, actor } = request; if (!scopes.includes(scope)) { return { allowed: false, reason: `"${scope}" is not a scope this actor belongs to` }; } const own = actorScope(actor); if (scope === own) { return { allowed: true }; } if (author.kind !== 'human') { return { allowed: false, reason: `only a human may author a skill at "${scope}"; an agent may write only at "${own}"`, }; } return request.elevated === true ? { allowed: true } : { allowed: false, reason: `authoring at "${scope}" requires an elevated human author` }; } //# sourceMappingURL=skills.js.map