UNPKG

@adonis-agora/agent

Version:

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

211 lines • 9.36 kB
import { readdir } from 'node:fs/promises'; import { extname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { isBrandedFunctionalTool, readAiToolMeta, } from './ai-tool-ref.js'; /** * Register one module export into the registry if it is an `@AiTool` class or a `defineTool(...)` * functional tool; ignore anything else. A name already present is skipped (first wins). Returns the * registered descriptor, or `null` when the export was not a tool / duplicated a registered name. */ export function registerToolExport(registry, exported, defaultRoles, app) { // A functional tool: a branded { spec, handler } from `defineTool`. if (isBrandedFunctionalTool(exported)) { return registerFunctionalTool(registry, exported, defaultRoles); } // An `@AiTool` / `static tool` class: read its stamped metadata and register `execute`. const meta = readAiToolMeta(exported); if (meta === undefined || typeof exported !== 'function') { return null; } const cls = exported; // Validate it is a tool WITHOUT constructing it — `execute` lives on the prototype. Keeps the // fail-fast reject of a non-tool export while letting the instance be resolved lazily (app path). const proto = cls.prototype; if (typeof proto?.execute !== 'function') { return null; } if (registry.has(meta.name)) { return null; } // `isEnabled()` / `canUse()` / `describe()` live on the tool CLASS, while the registry holds the // wrapper built here. Each is forwarded only when the class declares it — a gate left behind on // the class would be one an app declares, sees no error for, and that never runs. const declares = (method) => typeof proto?.[method] === 'function'; let resolveInstance; if (app === undefined) { // No container: instantiate now (no DI); skip if construction throws — pre-DI behavior. const instance = instantiate(cls); if (instance === null) { return null; } resolveInstance = () => Promise.resolve(instance); } else { // Container DI: resolve the tool (and its `@inject`'d deps) through the IoC container — the // idiomatic Adonis way. LAZY on first use and then cached: discovery runs in the provider's // `boot()`, before the app is fully booted, so an eager `container.make()` could fail resolving // a peer service — the same reason the Lucid store factory resolves lazily. let instancePromise; resolveInstance = () => { instancePromise ??= app.container.make(cls); return instancePromise; }; } const handler = { execute: async (input, ctx) => (await resolveInstance()).execute(input, ctx), ...(declares('present') ? { present: async (output, ctx) => (await resolveInstance()).present?.(output, ctx) } : {}), ...(declares('preflight') ? { preflight: async (input, ctx, options) => (await resolveInstance()).preflight?.(input, ctx, options) ?? { status: 'ready', }, } : {}), // Called through the instance so each keeps its `this` (and its injected deps). ...(declares('isEnabled') ? { isEnabled: async () => (await resolveInstance()).isEnabled?.() ?? true } : {}), ...(declares('canUse') ? { canUse: async (actor) => (await resolveInstance()).canUse?.(actor) ?? true } : {}), ...(declares('describe') ? { describe: async (scope) => (await resolveInstance()).describe?.(scope), } : {}), }; registry.register(specFromMeta(meta, defaultRoles), handler); return { name: meta.name, source: 'class' }; } /** Register a `defineTool` functional tool, applying `defaultRoles` when it declares none. */ export function registerFunctionalTool(registry, tool, defaultRoles) { if (registry.has(tool.spec.name)) { return null; } const spec = tool.spec.roles === undefined ? { ...tool.spec, roles: defaultRoles } : tool.spec; registry.register(spec, tool.handler); return { name: tool.spec.name, source: 'functional' }; } function specFromMeta(meta, defaultRoles) { return { name: meta.name, kind: meta.kind, description: meta.description, inputSchema: meta.input, roles: meta.roles ?? defaultRoles, ...(meta.ability !== undefined ? { ability: meta.ability } : {}), ...(meta.enabled !== undefined ? { enabled: meta.enabled } : {}), ...(meta.presentation !== undefined ? { presentation: meta.presentation } : {}), ...(meta.replacementKey !== undefined ? { replacementKey: meta.replacementKey } : {}), ...(meta.terminal === true ? { terminal: true } : {}), }; } function instantiate(cls) { try { const Ctor = cls; return new Ctor(); } catch { return null; } } /** * Pick the module extension to import from what the scanned directory ACTUALLY holds: `.ts` when it * has any non-declaration `.ts` file, else `.js`. At runtime an app runs from EITHER its source * (`app/agent_tools/*.ts`, dev under a TS loader) OR its build (`build/app/agent_tools/*.js`, TS * compiled away) — never both in the same directory — so choosing one gate still guarantees a built * `.js` and a dev `.ts` of the same module never double-register. * * This must be derived from the app's own files, NOT from `extname(import.meta.url)`: this module * ships COMPILED (always `.js`), so keying off its own extension would make a `.ts` dev app scan for * `.js` tools it doesn't have and register nothing. */ function moduleExtensionFor(entries) { const hasTsSource = entries.some((entry) => extname(entry) === '.ts' && !entry.endsWith('.d.ts')); return hasTsSource ? '.ts' : '.js'; } /** * Scan `dir` RECURSIVELY for modules and register every exported `@AiTool` class / `defineTool` * tool into `registry` — the `app/agent_tools` convention. Only the environment-appropriate extension * is imported and each export is visited once (deduped), so a built `.js` and a dev `.ts` of the same * module never both register. Missing directory → no-op (the convention is opt-in). `defaultRoles` * fills a tool that declares no `roles` (`[]` by default: unrestricted). */ export async function discoverTools(registry, dir, defaultRoles, app) { let entries; try { entries = await readdir(dir, { recursive: true }); } catch (err) { if (err.code === 'ENOENT') { return []; } throw err; } const moduleExt = moduleExtensionFor(entries); const registered = []; const seen = new Set(); for (const entry of entries.sort()) { if (extname(entry) !== moduleExt || entry.endsWith(`.d${moduleExt}`)) { continue; } // Import each tool file in its own try/catch: a single file that throws at import time (e.g. a // top-level `@adonisjs/*/services/*` singleton whose `app` is undefined during boot) must NOT abort // the whole scan and silently leave the agent with ZERO tools. Log it loudly and skip — the other // tools still register, and the failure is visible instead of surfacing as the model "narrating" // tool calls it was never given. let mod; try { mod = (await import(pathToFileURL(join(dir, entry)).href)); } catch (err) { const logger = app ?.logger; const message = `@adonis-agora/agent: failed to import tool file "${entry}" during discovery — skipping it (the agent will run WITHOUT this tool until the import is fixed).`; if (logger?.error) { logger.error({ err }, message); } else { console.error(message, err); } continue; } for (const exported of Object.values(mod)) { if (seen.has(exported)) { continue; } seen.add(exported); const result = registerToolExport(registry, exported, defaultRoles, app); if (result !== null) { registered.push(result); } } } return registered; } /** * Register every `@AiTool` / `defineTool` reachable from a generated {@link ToolsBarrel} — the * build-time equivalent of {@link discoverTools} with no runtime `readdir`. Each module is imported * once and each export registered once (deduped). */ export async function registerToolsFromBarrel(registry, barrel, defaultRoles, app) { const registered = []; const seen = new Set(); for (const load of Object.values(barrel)) { const mod = await load(); for (const exported of Object.values(mod)) { if (seen.has(exported)) { continue; } seen.add(exported); const result = registerToolExport(registry, exported, defaultRoles, app); if (result !== null) { registered.push(result); } } } return registered; } //# sourceMappingURL=tool-discovery.js.map