@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
291 lines • 15.7 kB
TypeScript
/**
* 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.
*/
import type { StandardSchemaV1 } from '@standard-schema/spec';
import type { Actor, PageContext, ToolDefinition } from './types.js';
/** How a skill is identified in the catalog, to the model and to a `/`-autocomplete alike. */
export interface SkillSummary {
/** Unique within a scope. The handle the model passes to the `skill` tool. */
name: string;
/** One line: what task this covers, and therefore when to load it. Read by the MODEL to choose. */
description: string;
/** The opaque scope token this skill is published at — see {@link ScopeResolver}. */
scope: string;
}
/** A skill with the instructions themselves. Only ever materialized when something loads it. */
export interface Skill extends SkillSummary {
body: string;
}
/** Whose turn is asking, and therefore which scopes apply. */
export interface ScopeContext {
actor: Actor;
threadId: string;
/** The agent running this turn. Undefined → the default agent. */
agentName?: string;
pageContext?: PageContext;
}
/**
* The skills-facing name for {@link ScopeContext}, so a `SkillProvider`'s signature reads in its own
* vocabulary. Deliberately the SAME type rather than a parallel one: skills and memory resolve
* scopes through one {@link ScopeResolver}, and a deployment that could answer "which scopes does
* this actor have" twice would eventually answer it differently.
*/
export type SkillContext = ScopeContext;
/** The scope token every deployment has: skills nobody narrowed. */
export declare const GLOBAL_SCOPE = "global";
/** The token for one actor's own skills. */
export declare function actorScope(actor: Actor): string;
/** The token for a tenant's skills — `Actor.tenantRef` is the only tenant key this library knows. */
export declare function tenantScope(tenantRef: string): string;
/**
* Which scopes a turn may draw from, MOST SPECIFIC FIRST. Precedence is the order: an entry from an
* earlier token outranks a same-named one from a later token, and the model is shown which won.
*
* A HOST-SUPPLIED FUNCTION rather than an enum this library owns, because the axes a deployment
* scopes by are the deployment's own. `Actor` gives an id and a tenant; it does not give a sector, a
* squadron, a base, a shift — and every one of those is a real axis in some consumer. An enum here
* would make each of them a schema change in a library that has no business knowing they exist,
* while a token is a string a host mints for itself. Return `['sector:logistics', 'tenant:base-7',
* 'global']` and precedence follows, with nothing in this package edited.
*
* MUST be a pure function of its context. It runs inside the `skills:catalog` / `memory:digest`
* checkpoint and its result is journaled, so a resumed run reads back the scopes the first attempt
* resolved rather than asking a membership table that may have changed since — which would let a
* run's prompt differ from the one its journal records. Read a database here at your peril; read it
* in the provider, whose answer is journaled at the same position.
*/
export interface ScopeResolver {
resolve(ctx: ScopeContext): string[] | Promise<string[]>;
}
/**
* 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 declare const defaultScopeResolver: ScopeResolver;
/** Arguments to {@link SkillProvider.list}. */
export interface ListSkillsInput {
scopes: readonly string[];
ctx: SkillContext;
}
/**
* Arguments to {@link SkillProvider.load}. An object rather than positional arguments because `name`
* and `scope` are both strings: transposed, a positional call compiles clean, returns `null`, and
* the skill silently fails to load.
*/
export interface LoadSkillInput {
name: string;
scope: string;
ctx: SkillContext;
}
/**
* Where skills come from. Deliberately two calls rather than one: `list` is asked on EVERY turn and
* must stay cheap, while a body is read only when the model decides it needs that procedure — the
* whole point of the surface. A provider over a table selects name/description/scope for the first
* and one row for the second.
*
* This library owns no skill table. A skill's real scoping axes, its authoring UI and its audit
* trail are all the host's, and a host that related its own entity into a table this package created
* at boot would be writing migrations against a schema the boot-time heal also edits. Tokens split
* it the other way round: the library owns the contract (what a scope means, how precedence works,
* what is journaled), the host owns the rows.
*/
export interface SkillProvider {
/**
* Every skill visible at `scopes`, in any order — this library sorts and resolves precedence. A
* provider MAY return skills outside `scopes`; they are dropped rather than trusted, so a filter
* bug in a host cannot widen what an actor is offered.
*/
list(input: ListSkillsInput): SkillSummary[] | Promise<SkillSummary[]>;
/**
* The instructions for one skill, or `null` when it is gone. Called only from inside the loading
* checkpoint, so the body it returns is journaled and every replay reads THAT text back — an
* edited skill never rewrites the prompt of a run already in flight.
*/
load(input: LoadSkillInput): string | null | Promise<string | null>;
}
/** A source of skills that is a fixed list — module config, or a host's own constant. */
export declare function staticSkillProvider(skills: readonly Skill[]): SkillProvider;
/**
* 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 declare function compositeSkillProvider(providers: readonly SkillProvider[]): SkillProvider;
/**
* One skill as it is offered — to the model in the catalog block, and to a client listing what a
* user may type after a `/`. Both read the same list, built by the same call, so what a user can
* reach and what the model can reach cannot drift apart.
*/
export interface SkillCatalogEntry {
name: string;
description: string;
/** The scope token it resolved from — the provenance a user is entitled to see. */
scope: string;
/**
* Scope tokens of same-named skills this one outranks, widest last. Present ONLY when something
* was shadowed, so a reader can tell "there is no org default" from "there is one and yours wins".
* The model is shown it for the same reason: a silent override is indistinguishable from an
* instruction nobody wrote, and the user can only be told "your setting differs from the org
* default" by something that knows both existed.
*/
shadows?: string[];
}
/** What a turn resolved — journaled whole, so the prompt is reconstructible from the journal alone. */
export interface SkillOffer {
/** The scope tokens this turn drew from, most specific first. */
scopes: string[];
/** The skills offered, most specific first then by name. */
entries: SkillCatalogEntry[];
/** Applicable skills `maxSkills` left out. Non-zero means the catalog is not the whole truth. */
omitted: number;
}
/**
* 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 declare const DEFAULT_MAX_SKILLS = 20;
/** How a turn reaches its skills. See `AgentLoopDeps.skills`. */
export interface SkillsConfig {
provider: SkillProvider;
/** Undefined → {@link defaultScopeResolver}: the actor's own, their tenant's, the deployment's. */
scopes?: ScopeResolver;
/** Undefined → {@link DEFAULT_MAX_SKILLS}. */
maxSkills?: number;
}
/**
* 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 declare function resolveSkillCatalog(summaries: readonly SkillSummary[], scopes: readonly string[], maxSkills?: number): Omit<SkillOffer, 'scopes'>;
/** Resolve the scopes and the catalog for one turn. Called INSIDE the loop's `skills:catalog` step. */
export declare function offerSkills(config: SkillsConfig, ctx: SkillContext): Promise<SkillOffer>;
/** The reserved name of the built-in skill-loading tool. */
export declare 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 declare function buildSkillsBlock(entries: readonly SkillCatalogEntry[]): string;
/** What the model passes to the `skill` tool. */
export interface SkillToolInput {
name: string;
}
/**
* 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 declare const skillInputSchema: StandardSchemaV1<unknown, SkillToolInput>;
export declare 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 \u2014 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 declare function skillToolDefinition(): ToolDefinition;
/** What a `skill` call resolved to — the body, or why it did not. */
export type SkillLoadOutcome = {
ok: true;
skill: Skill;
shadows?: string[];
} | {
ok: false;
error: string;
};
/**
* 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 declare function loadSkill(args: {
config: SkillsConfig;
offer: SkillOffer;
name: string;
ctx: SkillContext;
}): Promise<SkillLoadOutcome>;
/** Who is trying to write a skill. */
export interface SkillAuthor {
/**
* `'human'` is a person acting through a UI; `'agent'` is anything else — a tool, a turn, a batch
* job. The distinction is the whole of the rule below, so it is not inferable and has to be stated
* by the caller.
*/
kind: 'human' | 'agent';
actorRef?: string;
}
/** A request to author a skill at a scope. */
export interface SkillWriteRequest {
/** The scope token the skill would be published at. */
scope: string;
actor: Actor;
/** The actor's resolved scopes, most specific first — the same list a turn draws from. */
scopes: readonly string[];
author: SkillAuthor;
/**
* The host's own answer to "may this person administer that scope" — a sector lead editing their
* sector's skills, an operator editing the deployment's. This library cannot know it: it has no
* notion of who administers `sector:logistics`, and inventing one would be a second, weaker
* authorization model next to the host's real one. Undefined/false → a wider scope is refused.
*/
elevated?: boolean;
}
export type SkillWriteVerdict = {
allowed: true;
} | {
allowed: false;
reason: string;
};
/**
* 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 declare function skillWriteVerdict(request: SkillWriteRequest): SkillWriteVerdict;
//# sourceMappingURL=skills.d.ts.map