UNPKG

@adonis-agora/agent

Version:

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

451 lines • 21.1 kB
async function resolveLucidDatabase(app) { return (await app.container.make('lucid.db')); } /** * Marker carrying a `stores.lucid()` factory's configured connection, so the provider can default * `pricingStore`/`governanceQueries` to the SAME Lucid connection as the main store. `null` means the * app's default connection (no `connection` was passed). */ const LUCID_STORE_CONNECTION = Symbol.for('@adonis-agora/agent:lucid-store-connection'); function tagLucidStore(factory, connection) { Object.defineProperty(factory, LUCID_STORE_CONNECTION, { value: connection ?? null, enumerable: false, }); return factory; } /** * If `factory` is a `stores.lucid()` store factory, returns its connection (`null` = the app's default * connection); otherwise `undefined`. The provider uses this to mirror the main store's Lucid * connection when `pricingStore`/`governanceQueries` are left at their defaults. */ export function lucidStoreConnection(factory) { if (typeof factory !== 'function') return undefined; const value = factory[LUCID_STORE_CONNECTION]; return value === undefined ? undefined : value; } /** * The store factory namespace used in `config/agent.ts`: * * ```ts * import { defineConfig, stores } from '@adonis-agora/agent' * * export default defineConfig({ * store: 'lucid', * stores: { * memory: stores.memory(), * lucid: stores.lucid({ connection: 'pg' }), * }, * }) * ``` * * Each factory returns a {@link StoreFactory} — a lazy thunk. Calling it in the config file costs * nothing; the peer dependency is only imported when the provider builds the selected store at boot. */ export const stores = { /** In-memory store — single-process, non-durable. Handy for tests and scratch/dev apps. */ memory(_config = {}) { return async () => { const { InMemoryAgentStore } = await import('../testing/in-memory-store.js'); return new InMemoryAgentStore(); }; }, /** Persist threads/messages/tool-calls/usage in SQL via `@adonisjs/lucid`. */ lucid(config = {}) { const factory = async (ctx) => { const db = await resolveLucidDatabase(ctx.app); const { LucidAgentStore } = await import('./lucid.js'); const client = (config.connection !== undefined ? db.connection(config.connection) : db); return new LucidAgentStore(client, { ...(config.autoCreateTables !== undefined ? { autoCreateTables: config.autoCreateTables } : {}), }); }; // Tag with the connection so the provider can mirror it for pricing/governance defaults. return tagLucidStore(factory, config.connection); }, }; /** * The pricing factory namespace used in `config/agent.ts`, mirroring {@link stores}: * * ```ts * export default defineConfig({ * pricingStore: pricingStores.lucid(), * }) * ``` * * Seed model prices once (e.g. in a command) with `seedModelPrices(store, [...])`. */ export const pricingStores = { /** In-memory pricing table — for tests and the offline demo. */ memory() { return async () => { const { InMemoryPricingStore } = await import('../testing/in-memory-pricing.js'); return new InMemoryPricingStore(); }; }, /** Persist per-model prices in SQL (the `agent_model_pricing` table) via `@adonisjs/lucid`. */ lucid(config = {}) { return async (ctx) => { const db = await resolveLucidDatabase(ctx.app); const { LucidPricingStore } = await import('./lucid-pricing.js'); const client = (config.connection !== undefined ? db.connection(config.connection) : db); return new LucidPricingStore(client, { ...(config.autoCreateTables !== undefined ? { autoCreateTables: config.autoCreateTables } : {}), }); }; }, }; /** * The governance-queries factory namespace used in `config/agent.ts`, mirroring {@link pricingStores}: * * ```ts * export default defineConfig({ * pricingStore: pricingStores.lucid(), * governanceQueries: governanceQueries.lucid(), * }) * ``` * * The Lucid read-model aggregates the same five agent tables the store writes and prices cost against * the configured `pricingStore`; wiring it mounts the `/agent/governance/*` read routes. */ export const governanceQueries = { /** Aggregate the five agent tables in SQL via `@adonisjs/lucid`, priced against the pricing store. */ lucid(config = {}) { return async (ctx) => { const db = await resolveLucidDatabase(ctx.app); const { LucidGovernanceQueries } = await import('./lucid-governance-queries.js'); const client = (config.connection !== undefined ? db.connection(config.connection) : db); return new LucidGovernanceQueries(client, ctx.pricingStore, { ...(config.autoCreateTables !== undefined ? { autoCreateTables: config.autoCreateTables } : {}), }); }; }, }; /** * The retriever factory namespace used in `config/agent.ts`, mirroring {@link stores}: * * ```ts * export default defineConfig({ * model: () => aiSdkModel(openai('gpt-4o-mini')), * retriever: retrievers.memory({ embedder: myEmbedder, documents: [...] }), * retrievalTopK: 5, * }) * ``` * * Use `retrievers.pgvector({...})` for the production pgvector/Lucid store or `retrievers.qdrant({...})` * for a managed Qdrant collection; `retrievers.memory` covers tests and small/embedded corpora. */ export const retrievers = { /** In-memory embedding retriever — cosine over a Map, single-process. Handy for tests and dev apps. */ memory(config) { return async () => { const { MemoryVectorStore } = await import('../rag/memory-vector-store.js'); const { EmbeddingRetriever } = await import('../rag/embedding-retriever.js'); const { ingestDocuments } = await import('../rag/ingest.js'); const embedder = typeof config.embedder === 'function' ? await config.embedder() : config.embedder; const store = new MemoryVectorStore(); if (config.documents !== undefined && config.documents.length > 0) { await ingestDocuments(config.documents, { embedder, store, ...(config.chunkSize !== undefined ? { chunkSize: config.chunkSize } : {}), ...(config.overlap !== undefined ? { overlap: config.overlap } : {}), }); } return new EmbeddingRetriever(embedder, store); }; }, /** * Production pgvector retriever — cosine/L2/inner similarity over a `vector(N)` column via * `@adonisjs/lucid` (raw SQL for the pgvector operators; the embedding is a `?::vector` binding). The * Lucid peer is imported lazily inside the thunk (unless a structural `db` is passed), so it stays * optional. Pass `ensureSchema: true` to provision the extension/table/index at boot for tests. */ pgvector(config) { return async (ctx) => { const { PgLexicalVectorStore, PgVectorStore, PgVectorRetriever } = await import('../rag/pg-vector-store.js'); const { ingestDocuments } = await import('../rag/ingest.js'); const embedder = typeof config.embedder === 'function' ? await config.embedder() : config.embedder; const db = config.db ?? (await (async () => { const dbService = await resolveLucidDatabase(ctx.app); return (config.connection !== undefined ? dbService.connection(config.connection) : dbService); })()); const storeOptions = { ...(config.table !== undefined ? { table: config.table } : {}), ...(config.dimension !== undefined ? { dimension: config.dimension } : {}), ...(config.metric !== undefined ? { metric: config.metric } : {}), ...(config.columns !== undefined ? { columns: config.columns } : {}), ...(config.nullableEmbeddings !== undefined ? { nullableEmbeddings: config.nullableEmbeddings } : {}), ...(config.iterativeScan !== undefined ? { iterativeScan: config.iterativeScan } : {}), ...(config.efSearch !== undefined ? { efSearch: config.efSearch } : {}), ...(config.upsertBatchSize !== undefined ? { upsertBatchSize: config.upsertBatchSize } : {}), }; const { hybrid: hybridOptions, ...fullText } = config.fullText ?? {}; const lexical = config.fullText !== undefined ? new PgLexicalVectorStore(db, { ...storeOptions, fullText }) : undefined; const store = lexical ?? new PgVectorStore(db, storeOptions); if (config.ensureSchema === true) { await store.ensureSchema(); } if (config.documents !== undefined && config.documents.length > 0) { await ingestDocuments(config.documents, { embedder, store, ...(config.chunkSize !== undefined ? { chunkSize: config.chunkSize } : {}), ...(config.overlap !== undefined ? { overlap: config.overlap } : {}), }); } if (lexical !== undefined) { const { HybridRetriever } = await import('../rag/hybrid-retriever.js'); const { LexicalRetriever } = await import('../rag/lexical-retriever.js'); return new HybridRetriever([new PgVectorRetriever(embedder, lexical), new LexicalRetriever(lexical)], hybridOptions); } return new PgVectorRetriever(embedder, store); }; }, /** * Production Qdrant retriever — cosine/dot/euclid via `@qdrant/js-client-rest`. The client is * imported LAZILY inside the thunk (unless a structural `client` is passed), so the package stays an * OPTIONAL peer. Pass `ensureCollection: true` to provision the collection at boot. */ qdrant(config) { return async () => { const { QdrantStore, QdrantRetriever } = await import('../rag/qdrant-store.js'); const { ingestDocuments } = await import('../rag/ingest.js'); const embedder = typeof config.embedder === 'function' ? await config.embedder() : config.embedder; const client = config.client ?? (await (async () => { const mod = await import('@qdrant/js-client-rest'); return new mod.QdrantClient({ url: config.url ?? 'http://localhost:6333', ...(config.apiKey !== undefined ? { apiKey: config.apiKey } : {}), }); })()); const store = new QdrantStore(client, { ...(config.collection !== undefined ? { collection: config.collection } : {}), ...(config.dimension !== undefined ? { dimension: config.dimension } : {}), ...(config.metric !== undefined ? { metric: config.metric } : {}), }); if (config.ensureCollection === true) { await store.ensureCollection(); } if (config.documents !== undefined && config.documents.length > 0) { await ingestDocuments(config.documents, { embedder, store, ...(config.chunkSize !== undefined ? { chunkSize: config.chunkSize } : {}), ...(config.overlap !== undefined ? { overlap: config.overlap } : {}), }); } return new QdrantRetriever(embedder, store); }; }, }; /** * The attachment-store factory namespace used in `config/agent.ts`: * * ```ts * export default defineConfig({ * model, * attachments: attachmentStores.media(), // uploads on @adonis-agora/media, any Drive disk * }) * ``` * * Bring your own storage by passing an {@link AttachmentStagingStore} instance instead. */ export const attachmentStores = { /** * Uploads stored through `@adonis-agora/media` (register `@adonis-agora/media/media_provider` * and configure `config/media.ts`): media rows in their own collection, one owner per actor, on * any Drive disk. The url the model fetches is minted per request — signed and short-lived by * default — so it is never taken from the client. See `MediaAttachmentsOptions`. */ media(options = {}) { return async ({ app, agentStore }) => { // A variable specifier: `@adonis-agora/media` is an optional peer, loaded only when selected. const mediaPackage = '@adonis-agora/media'; const media = (await import(mediaPackage)); const manager = (await app.container.make(media.MediaManager)); const { MediaAttachmentStaging } = await import('../media/media-attachment-staging.js'); return new MediaAttachmentStaging({ storage: manager.storage, store: manager.store, ...(agentStore !== undefined ? { agentStore } : {}), // `uploads.resumable` in config/media.ts → resumable (tus) uploads, else multipart only. uploads: manager.hasResumable === true ? (manager.resumable ?? null) : null, }, options); }; }, /** * In memory — the uploaded bytes become a `data:` url the model reads inline. Single-process and * lost on restart: tests and demos. `limits` sets the size cap / content types it declares. */ memory(limits = {}) { return async () => { const { InMemoryAttachmentStagingStore } = await import('../testing/in-memory-attachment-staging.js'); return new InMemoryAttachmentStagingStore(limits); }; }, }; /** * The token-sink factory namespace used in `config/agent.ts`, mirroring {@link stores}. Also exported as * `streamTransports` (an alias — pick whichever name reads better): * * ```ts * import { defineConfig, tokenSinks } from '@adonis-agora/agent' * * export default defineConfig({ * model: () => aiSdkModel(openai('gpt-4o-mini')), * sink: tokenSinks.redis({ connection: 'main' }), // multi-replica; any pod serves any run's SSE * }) * ``` * * Omitting `sink` entirely keeps the in-process sink (single replica); `tokenSinks.memory()` selects it * explicitly. `tokenSinks.redis()` fans a run's tokens across replicas over Redis pub/sub + a replayable * list, keeping the SSE envelope byte-identical. The `@adonisjs/redis` peer is imported ONLY inside the * `redis` thunk, so it stays fully optional. `tokenSinks.lucid()` is the multi-replica sink for a * deployment WITHOUT Redis: frames are rows in the app's SQL database, read by polling. */ export const tokenSinks = { /** The in-process sink — per-run in-memory buffers, single replica. The default when `sink` is omitted. */ memory() { return async () => { const { InProcessTokenStreamSink } = await import('../in-process-sink.js'); return new InProcessTokenStreamSink(); }; }, /** * The Redis multi-replica sink: any pod can serve any run's SSE stream. Builds a * {@link RedisStreamClient} over `@adonisjs/redis` (imported lazily here), or uses `config.client` when * provided. Requires `@adonisjs/redis` installed + configured (`config/redis.ts`) unless a `client` is * passed. */ redis(config = {}) { return async (ctx) => { const { RedisTokenStreamSink } = await import('../redis-token-stream-sink.js'); const client = config.client ?? (await buildAdonisRedisClient(ctx.app, config.connection)); return new RedisTokenStreamSink(client, { ...(config.keyPrefix !== undefined ? { keyPrefix: config.keyPrefix } : {}), ...(config.ttlSeconds !== undefined ? { ttlSeconds: config.ttlSeconds } : {}), }); }; }, /** * The Lucid (SQL) multi-replica sink: any pod can serve any run's SSE stream, with no Redis — a * run's frames are rows of `agent_stream_frame` on the app's database, which subscribers poll. * Slower to deliver than Redis (up to `pollIntervalMs`) and it writes to your database on every * streamed answer; pick {@link tokenSinks.redis} when Redis is already there. Requires * `@adonisjs/lucid` installed + configured unless a `db` is passed. */ lucid(config = {}) { return async (ctx) => { const { LucidTokenStreamSink } = await import('../lucid-token-stream-sink.js'); const { connection, db: ownDb, ...options } = config; const manager = ownDb === undefined ? await resolveLucidDatabase(ctx.app) : undefined; const db = ownDb ?? (connection !== undefined ? manager?.connection(connection) : manager); return new LucidTokenStreamSink(db, Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined))); }; }, }; /** Alias of {@link tokenSinks}. */ export const streamTransports = tokenSinks; /** * Resolve `@adonisjs/redis` from the app container (`app.container.make('redis')`) and adapt its * connection to a {@link RedisStreamClient}. Uses the container — NOT `@adonisjs/redis/services/main` — * because that service singleton reads its own module-level `app`, which is `undefined` when the sink is * built during `AgentProvider.boot` (throws `Cannot read properties of undefined (reading 'booted')`). * The `app` handed in here is the live application, so it resolves correctly. The container binding is * only reached when the Redis sink is selected, so `@adonisjs/redis` stays a runtime-only peer (cast the * container access since the peer's `redis` binding type may be absent at build time). Uses a DEDICATED * duplicated connection per subscribe (a subscribed Redis connection can't run other commands). */ async function buildAdonisRedisClient(app, connection) { const container = app.container; const redis = await container.make('redis'); const conn = connection !== undefined ? redis.connection(connection) : redis.connection(); return adaptIoRedis(conn.ioConnection); } /** Adapt an `ioredis`-shaped client to {@link RedisStreamClient}, using a duplicated subscriber connection. */ function adaptIoRedis(io) { return { rpush: async (key, value) => { await io.rpush(key, value); }, lrange: (key, start, stop) => io.lrange(key, start, stop), set: async (key, value) => { await io.set(key, value); }, get: (key) => io.get(key), publish: async (channel, message) => { await io.publish(channel, message); }, subscribe: async (channel, onMessage) => { const subscriber = io.duplicate(); const listener = (incoming, message) => { if (incoming === channel) { onMessage(message); } }; subscriber.on('message', listener); await subscriber.subscribe(channel); return async () => { subscriber.off('message', listener); await subscriber.unsubscribe(channel); await subscriber.quit(); }; }, del: async (...keys) => { await io.del(...keys); }, expire: async (key, seconds) => { await io.expire(key, seconds); }, }; } /** * The actor-directory factory namespace used in `config/agent.ts`, mirroring {@link stores}: * * ```ts * export default defineConfig({ * actorDirectory: actorDirectories.memory({ labels: { u_1: 'Ada Lovelace' } }), * }) * ``` * * Omit `actorDirectory` → governance surfaces render raw opaque refs. A real deployment binds its own * directory over the host's user table by passing an instance; `actorDirectories.memory` covers tests, * the offline demo, and small fixed label tables. */ export const actorDirectories = { /** In-memory `actorRef → label` map — single-process. Handy for tests and dev apps. */ memory(config = {}) { return async () => { const { InMemoryActorDirectory } = await import('../testing/in-memory-actor-directory.js'); return new InMemoryActorDirectory(config.labels ?? {}); }; }, }; //# sourceMappingURL=factory.js.map