UNPKG

@adonis-agora/agent

Version:

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

304 lines • 15 kB
import { canonicalActionProposalJson, snapshotActionProposal, } from './action-proposal-transitions.js'; import { turnChannel } from './genui/channels.js'; import { filterToolsByRole, personaFilterTools } from './personas.js'; import { canActorUseTool, filterToolsByCanUse, filterToolsByEnabled, isToolEnabled, } from './tool-filters.js'; import { presentToolResult } from './tool-result-presentation.js'; import { createNoopEmitUi } from './tool-ui.js'; /** Thrown when an actor invokes a tool their role is not allowed. */ export class ToolForbiddenError extends Error { toolName; constructor(toolName) { super(`Tool "${toolName}" is not allowed for this role`); this.toolName = toolName; this.name = 'ToolForbiddenError'; } } /** * Thrown when a registered tool is invoked while this deployment has it turned off (`enabled` / * `isEnabled()`). Distinct from {@link ToolForbiddenError}, which is about the actor, and from * {@link ToolNotFoundError}, which is about a name nobody registered — an operator reading a log * needs to tell "you flipped the flag" apart from "that tool does not exist in this build". * * Reachable in normal operation, not just from a forged call: a HITL `action` approved before the * flag was turned off runs its tool afterwards. */ export class ToolDisabledError extends Error { toolName; constructor(toolName) { super(`Tool "${toolName}" is disabled in this deployment`); this.toolName = toolName; this.name = 'ToolDisabledError'; } } /** Thrown when a tool is invoked that was never registered. */ export class ToolNotFoundError extends Error { toolName; constructor(toolName) { super(`Tool "${toolName}" is not registered`); this.toolName = toolName; this.name = 'ToolNotFoundError'; } } /** Thrown when a tool's input fails its Standard Schema validation. */ export class ToolInputInvalidError extends Error { toolName; issues; constructor(toolName, issues) { super(`Invalid input for tool "${toolName}": ${issues.map((issue) => issue.message).join('; ')}`); this.toolName = toolName; this.issues = issues; this.name = 'ToolInputInvalidError'; } } export class ToolPreflightDeniedError extends Error { toolName; reason; constructor(toolName, reason) { super(reason); this.toolName = toolName; this.reason = reason; this.name = 'ToolPreflightDeniedError'; } } /** Trusted preparation or a fresh schema parse changed the approved JSON input. */ export class ToolInputDriftError extends Error { toolName; constructor(toolName) { super(`Tool "${toolName}" input differs from its trusted snapshot`); this.toolName = toolName; this.name = 'ToolInputDriftError'; } } function assertTrustedInput(name, value, approved) { try { if (canonicalActionProposalJson(value) === canonicalActionProposalJson(approved)) return; } catch { // A non-JSON value cannot match a trusted JSON snapshot, including an explicitly undefined // approvedInput. Ordinary invocations never serialize the schema's parsed value. } throw new ToolInputDriftError(name); } /** * Holds every registered tool and gates invocation. * * Note: `definitionsFor` returns NEUTRAL definitions (no `execute`). The agent loop runs * each tool itself as a (durable) step — so even read tools are not auto-executed by the * model. `action` tools additionally require HITL approval before the loop runs them. */ export class ToolRegistry { entries = new Map(); register(spec, handler) { this.entries.set(spec.name, { spec, handler }); } /** * Drop a tool, so it is neither offered to the model nor invocable. Returns whether there was one. * * For a tool whose EXISTENCE is not decided here: an MCP server may stop exporting a tool between * one `tools/list` and the next, and leaving the old spec registered would go on offering the * model a tool whose next call fails on somebody else's machine. Whoever registered a name is * responsible for deciding when it is gone — nothing in the registry itself expires. */ unregister(name) { return this.entries.delete(name); } has(name) { return this.entries.has(name); } hasPresentation(name) { return this.entries.get(name)?.handler.present !== undefined; } /** Used by a journaled already-completed preflight; never executes the domain handler. */ async presentResult(name, output, ctx) { const entry = this.entries.get(name); if (entry !== undefined) await presentToolResult(name, entry.handler, output, ctx); } /** * How a streamed call of `name` is previewed while its input arrives (`ToolHandler.previewInput`), * or `undefined`. A preview that fails to build is no preview: it never fails the turn. */ async previewInput(name, scope) { const handler = this.entries.get(name)?.handler; if (handler?.previewInput === undefined) return undefined; try { return (await handler.previewInput(scope)) ?? undefined; } catch { return undefined; } } spec(name) { return this.entries.get(name)?.spec; } allSpecs() { return [...this.entries.values()].map((entry) => entry.spec); } /** * The tools to offer the model for this actor+agent, after the four filter layers: what this * agent pinned, what this deployment has enabled, what this actor's role allows, and what each * tool's own `canUse` allows this actor. * * Every layer only ever removes tools, so no arrangement of them can widen what a turn reaches. * The allow-list goes FIRST, even though it is the narrowest statement: it is a pure name-set * match, while each of the three below it may be a round trip — an authz service, a feature-flag * store, an entitlement table — and this runs once per model step. */ async definitionsFor(actor, policy, allowedTools, scope = {}) { const visible = await this.visibleEntries(actor, policy, allowedTools); const definitions = await Promise.all(visible.map(async ({ spec, handler }) => { // After every gate: a tool this actor cannot reach is never asked to describe itself. const override = handler.describe === undefined ? undefined : await handler.describe({ actor, ...scope }); if (override?.available === false) return null; return { name: spec.name, kind: spec.kind, description: override?.description ?? spec.description, inputSchema: override?.inputSchema ?? spec.inputSchema, }; })); return definitions.filter((definition) => definition !== null); } /** * The specs {@link definitionsFor} offers the model, whole — the same four gates (allow-list, * enabled, role, `canUse`) in the same order. For a surface that lists what an actor can reach * (`GET <path>/tools`), which must never disagree with what the model is actually shown. */ async visibleSpecs(actor, policy, allowedTools) { return (await this.visibleEntries(actor, policy, allowedTools)).map(({ spec }) => spec); } async visibleEntries(actor, policy, allowedTools) { const pinnedNames = new Set(personaFilterTools(this.allSpecs(), allowedTools).map((spec) => spec.name)); const pinned = [...this.entries.values()].filter((entry) => pinnedNames.has(entry.spec.name)); const live = await filterToolsByEnabled(pinned); const allowedByRole = new Set((await filterToolsByRole(live.map((entry) => entry.spec), actor, policy)).map((spec) => spec.name)); const roleScoped = live.filter((entry) => allowedByRole.has(entry.spec.name)); return filterToolsByCanUse(roleScoped, actor); } /** * Run a tool. Re-checks that the tool is enabled, that the role allows it and that the tool's own * `canUse` admits the actor (defense-in-depth — a call can reach here from a replayed durable step * or an approval granted before the flag moved, neither of which went through `definitionsFor` * again) and re-parses the input. With `options.allowedTools` (the turn's persona allow-list) a * tool off that list is refused last, the same way the offer narrowed it. A tool whose * `describe()` answers `available: false` for the call's scope is refused as not registered. */ async validated(name, input, ctx, policy, options = {}) { const entry = this.entries.get(name); if (entry === undefined) { throw new ToolNotFoundError(name); } if (!(await isToolEnabled(entry.spec, entry.handler))) { throw new ToolDisabledError(name); } if (!(await policy.can(ctx.actor, entry.spec))) { throw new ToolForbiddenError(name); } if (!(await canActorUseTool(ctx.actor, entry.handler))) { throw new ToolForbiddenError(name); } // Last, like the persona filter on the offer: the same layers in the same order, so a call the // model was never offered under this persona cannot run because the model named it anyway. if (options.allowedTools !== undefined && !options.allowedTools.includes(name)) { throw new ToolForbiddenError(name); } // The offer also drops a tool whose `describe()` answers `available: false` for this scope (a // genui tool the renderer cannot draw). A call to it is refused as the unknown tool it was to // the model, so naming a tool it was not shown cannot run it. if (entry.handler.describe !== undefined) { const described = await entry.handler.describe(describeScopeOf(ctx)); if (described?.available === false) throw new ToolNotFoundError(name); } const validation = await entry.spec.inputSchema['~standard'].validate(input); if (validation.issues !== undefined) { throw new ToolInputInvalidError(name, validation.issues); } // `emitUi` is part of the context's contract; a caller without a conversation (a test, a script, // a JavaScript host) gets the no-op rather than a tool that crashes calling it. const withEmit = typeof ctx.emitUi === 'function' ? ctx : { ...ctx, emitUi: createNoopEmitUi(ctx.requestId) }; return { entry, value: validation.value, ctx: withEmit }; } async prepare(name, input, ctx, policy, options = {}) { return (await this.prepareValidated(name, input, ctx, policy, options)).preflight; } /** Expose the single schema parse; snapshotInput opts proposal callers into immutable JSON. */ async prepareValidated(name, input, ctx, policy, options = {}) { const { entry, value, ctx: withEmit } = await this.validated(name, input, ctx, policy, options); const normalized = options.snapshotInput === true ? snapshotActionProposal(value) : value; const hookInput = options.snapshotInput === true ? snapshotActionProposal(normalized) : value; const preflight = (entry.spec.kind === 'action' ? await entry.handler.preflight?.(hookInput, withEmit, { phase: 'prepare' }) : undefined) ?? { status: 'ready' }; // A hook can retain its input or mutate nested values after an await. Only the isolated // normalized snapshot can be approved; reject mutation before exposing its confirmation. if (options.snapshotInput === true) assertTrustedInput(name, hookInput, normalized); return { input: normalized, preflight }; } async invoke(name, input, ctx, policy, options = {}) { const { entry, value, ctx: withEmit } = await this.validated(name, input, ctx, policy, options); const trusted = Object.hasOwn(options, 'approvedInput'); if (trusted) assertTrustedInput(name, value, options.approvedInput); const executionInput = trusted ? snapshotActionProposal(value) : value; const hookInput = trusted ? snapshotActionProposal(executionInput) : value; const result = entry.spec.kind === 'action' ? await entry.handler.preflight?.(hookInput, withEmit, { phase: 'execute' }) : undefined; // The hook receives its own snapshot, so retained references cannot alter later execution. if (trusted) assertTrustedInput(name, hookInput, executionInput); if (result?.status === 'denied') throw new ToolPreflightDeniedError(name, result.reason); const output = result?.status === 'completed' ? result.output : await entry.handler.execute(executionInput, withEmit); await presentToolResult(name, entry.handler, output, withEmit); return output; } } /** * Default gate: one of the actor's roles must be in `spec.roles` (else the default roles). An empty * list means no restriction — the default: a tool is callable by whoever the actor resolver resolved, * an anonymous visitor included. `action` tools still park on approval regardless. */ export class DefaultRolesPolicy { defaultRoles; emptyRoles; constructor(defaultRoles = [], options = {}) { this.defaultRoles = defaultRoles; this.emptyRoles = options.emptyRoles ?? 'allow'; } can(actor, tool) { const allowed = tool.roles ?? this.defaultRoles; if (allowed.length === 0) return this.emptyRoles === 'allow'; return (actor.roles ?? []).some((role) => allowed.includes(role)); } } /** * The closed gate — {@link DefaultRolesPolicy} with `emptyRoles: 'deny'`: the actor needs a role the * tool declares (else one of the default roles). A tool with no `roles` and no default roles, or with * an explicitly empty list, reaches nobody. For apps where an empty list means "no one" — a computed * `roles` that can come out empty, a multi-tenant MCP surface. */ export class ClosedRolesPolicy extends DefaultRolesPolicy { constructor(defaultRoles = []) { super(defaultRoles, { emptyRoles: 'deny' }); } } /** The {@link ToolDescribeScope} a call's context stands for: what the offer was built for. */ function describeScopeOf(ctx) { return { actor: ctx.actor, ...(ctx.threadId ? { threadId: ctx.threadId } : {}), ...(ctx.agentName !== undefined ? { agentName: ctx.agentName } : {}), ...(ctx.uiCapabilities !== undefined ? { uiCapabilities: ctx.uiCapabilities } : {}), channel: ctx.channel ?? turnChannel(ctx.pageContext), }; } //# sourceMappingURL=tool-registry.js.map