@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
JavaScript
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