UNPKG

@adonis-agora/agent

Version:

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

151 lines • 6.45 kB
import { createHash } from 'node:crypto'; import { AGENT_TABLES, ensureChannelStateTable, rowsOf } from '../stores/lucid-schema.js'; import { isMySql, portableSql } from '../stores/sql-dialect.js'; /** Process-local {@link ChannelStore}: a single replica, tests, development. */ export class InMemoryChannelStore { maxEntries; #entries = new Map(); /** @param maxEntries Past this many live keys, the oldest are forgotten first. */ constructor(maxEntries = 50_000) { this.maxEntries = maxEntries; } #live(key) { const entry = this.#entries.get(key); if (entry === undefined) return undefined; if (entry.expiresAt > Date.now()) return entry; this.#entries.delete(key); return undefined; } #put(key, value, ttlMs) { const now = Date.now(); this.#entries.delete(key); this.#entries.set(key, { value, expiresAt: now + ttlMs }); if (this.#entries.size <= this.maxEntries) return; // Insertion order is write order: the first keys are the oldest. for (const [stored, entry] of this.#entries) { if (this.#entries.size <= this.maxEntries && entry.expiresAt > now) break; this.#entries.delete(stored); } } claim(key, ttlMs) { if (this.#live(key) !== undefined) return false; this.#put(key, null, ttlMs); return true; } get(key) { return this.#live(key)?.value ?? null; } set(key, value, ttlMs) { this.#put(key, value, ttlMs); } delete(key) { this.#entries.delete(key); } } /** A {@link ChannelStore} shared by every replica, over Redis (`SET … PX … NX` for claims). */ export function redisChannelStore(redis, options = {}) { const prefix = options.prefix ?? 'agora:channel:'; const ttl = (ms) => Math.max(1, Math.round(ms)); return { async claim(key, ttlMs) { return (await redis.set(`${prefix}${key}`, '', 'PX', ttl(ttlMs), 'NX')) === 'OK'; }, get: (key) => redis.get(`${prefix}${key}`), async set(key, value, ttlMs) { await redis.set(`${prefix}${key}`, value, 'PX', ttl(ttlMs)); }, async delete(key) { await redis.del(`${prefix}${key}`); }, }; } /** Keys longer than the column are stored by their hash. */ const storedKey = (key) => key.length <= 255 ? key : `sha256:${createHash('sha256').update(key).digest('hex')}`; /** How many rows a raw write touched, whatever the driver wraps it in. */ function affected(result) { // mysql2: `[ResultSetHeader, fields]`. Everything else here answers `RETURNING` with rows. const head = Array.isArray(result) ? result[0] : result; const count = head?.affectedRows; if (typeof count === 'number') return count; return rowsOf(result).length; } /** * A {@link ChannelStore} in the app's SQL database through Lucid — shared by every replica, no Redis * needed. One row per key in `agent_channel_state`; a claim is one `INSERT` that skips on a duplicate * key (`ON CONFLICT DO NOTHING`, `INSERT IGNORE` on MySQL), so the primary key is the lock. Expired * rows are deleted as claims come in (`purgeEveryMs`), and an expired key can be taken again. * * ```ts * import db from '@adonisjs/lucid/services/db' * channels.handle(adapter, { …, store: lucidChannelStore(db) }) * ``` */ export function lucidChannelStore(database, options = {}) { const db = options.connection !== undefined && typeof database.connection === 'function' ? database.connection(options.connection) : database; const table = options.table ?? AGENT_TABLES.channelState; const purgeEveryMs = options.purgeEveryMs ?? 10 * 60 * 1000; const mysql = isMySql(db); const sql = (statement) => portableSql(db, statement); let ready; let lastPurge = 0; const init = () => { if (options.autoCreateTables === false) return Promise.resolve(); ready ??= ensureChannelStateTable(db, table).catch((error) => { ready = undefined; throw error; }); return ready; }; const purge = async (now) => { if (now - lastPurge < purgeEveryMs) return; lastPurge = now; await db.rawQuery(sql(`DELETE FROM "${table}" WHERE "expires_at" <= ?`), [now]); }; return { async claim(key, ttlMs) { await init(); const now = Date.now(); await purge(now); const id = storedKey(key); // An expired claim is free again. await db.rawQuery(sql(`DELETE FROM "${table}" WHERE "key" = ? AND "expires_at" <= ?`), [ id, now, ]); const bindings = [id, now + ttlMs, now]; const insert = mysql ? sql(`INSERT IGNORE INTO "${table}" ("key", "value", "expires_at", "created_at") VALUES (?, NULL, ?, ?)`) : `INSERT INTO "${table}" ("key", "value", "expires_at", "created_at") VALUES (?, NULL, ?, ?) ON CONFLICT ("key") DO NOTHING RETURNING "key"`; return affected(await db.rawQuery(insert, bindings)) === 1; }, async get(key) { await init(); const rows = rowsOf(await db.rawQuery(sql(`SELECT "value" FROM "${table}" WHERE "key" = ? AND "expires_at" > ?`), [storedKey(key), Date.now()])); const value = rows[0]?.value; return typeof value === 'string' ? value : null; }, async set(key, value, ttlMs) { await init(); const now = Date.now(); const bindings = [storedKey(key), value, now + ttlMs, now]; await db.rawQuery(mysql ? sql(`INSERT INTO "${table}" ("key", "value", "expires_at", "created_at") VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE "value" = VALUES("value"), "expires_at" = VALUES("expires_at")`) : `INSERT INTO "${table}" ("key", "value", "expires_at", "created_at") VALUES (?, ?, ?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = excluded."value", "expires_at" = excluded."expires_at"`, bindings); }, async delete(key) { await init(); await db.rawQuery(sql(`DELETE FROM "${table}" WHERE "key" = ?`), [storedKey(key)]); }, }; } //# sourceMappingURL=store.js.map