UNPKG

@adonis-agora/agent

Version:

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

444 lines • 17.6 kB
import { randomBytes } from 'node:crypto'; import { forDialect } from '../stores/lucid-schema.js'; import { isMySql } from '../stores/sql-dialect.js'; /** `evt_<seq in base 36>_<random>`: unique, and its position survives the event being pruned. */ export function newEventId(seq) { return `evt_${seq.toString(36)}_${randomBytes(6).toString('base64url')}`; } /** The position an event id was minted at, or `null` for an id this surface never minted. */ export function eventIdSeq(id) { const match = /^evt_([0-9a-z]{1,11})_[A-Za-z0-9_-]+$/.exec(id); if (!match?.[1]) return null; const seq = Number.parseInt(match[1], 36); return Number.isSafeInteger(seq) && seq > 0 ? seq : null; } function toEvent(seq, body, now) { return { seq, event: { id: newEventId(seq), created_at: new Date(now).toISOString(), ...body }, }; } // ── In memory ───────────────────────────────────────────────────────────────── /** Process-local {@link PoppyStore} — tests and single-instance dev. */ export class InMemoryPoppyStore { #conversations = new Map(); #events = new Map(); #messages = new Map(); #leases = new Map(); async createConversation(record) { this.#conversations.set(record.id, structuredClone(record)); } async getConversation(id) { const found = this.#conversations.get(id); return found ? structuredClone(found) : null; } async updateConversation(id, patch) { const found = this.#conversations.get(id); if (!found) return; Object.assign(found, structuredClone(patch), { updatedAt: Date.now() }); } async appendEvent(conversationId, body, options = {}) { const list = this.#events.get(conversationId) ?? []; this.#events.set(conversationId, list); if (options.key !== undefined) { const existing = list.find((entry) => entry.key === options.key); if (existing) { return { event: { seq: existing.seq, event: existing.event }, created: false }; } } const conversation = this.#conversations.get(conversationId); const last = list.at(-1)?.seq ?? conversation?.prunedSeq ?? 0; const stored = toEvent(last + 1, body, Date.now()); list.push({ ...stored, key: options.key ?? null }); return { event: structuredClone(stored), created: true }; } async listEvents(conversationId, afterSeq, limit) { return (this.#events.get(conversationId) ?? []) .filter((entry) => entry.seq > afterSeq) .slice(0, limit) .map((entry) => structuredClone({ seq: entry.seq, event: entry.event })); } async findEventSeq(conversationId, eventId) { return ((this.#events.get(conversationId) ?? []).find((entry) => entry.event.id === eventId)?.seq ?? null); } async pruneEvents(conversationId, before) { const list = this.#events.get(conversationId) ?? []; const cutoff = new Date(before).toISOString(); const last = list.at(-1); const keep = list.filter((entry) => entry === last || entry.event.created_at >= cutoff); const dropped = list.filter((entry) => !keep.includes(entry)); if (dropped.length === 0) return; this.#events.set(conversationId, keep); const conversation = this.#conversations.get(conversationId); if (conversation) { conversation.prunedSeq = Math.max(conversation.prunedSeq, ...dropped.map((e) => e.seq)); } } async claimMessage(owner, messageId, receipt) { const key = `${owner}\n${messageId}`; const found = this.#messages.get(key); if (found) return { status: 'existing', ...structuredClone(found) }; this.#messages.set(key, structuredClone(receipt)); return { status: 'claimed' }; } async releaseMessage(owner, messageId) { this.#messages.delete(`${owner}\n${messageId}`); } async leaseReader(conversationId, holder, ttlMs) { const now = Date.now(); const lease = this.#leases.get(conversationId); if (lease && lease.holder !== holder && lease.until > now) return false; this.#leases.set(conversationId, { holder, until: now + ttlMs }); return true; } async releaseReader(conversationId, holder) { if (this.#leases.get(conversationId)?.holder === holder) this.#leases.delete(conversationId); } } // ── Lucid ───────────────────────────────────────────────────────────────────── export const POPPY_TABLES = { conversations: 'agent_poppy_conversation', events: 'agent_poppy_event', messages: 'agent_poppy_message', }; /** * `CREATE TABLE IF NOT EXISTS` DDL for the Poppy tables — the agent tables' portable dialect * (quoted identifiers, epoch-ms `BIGINT`, `TEXT` JSON; `forDialect` adapts it to MySQL). An event's * primary key is its position, so two appends racing for one position cannot both win; a message's * is its (owner, id), the idempotency lock of §7.3. */ export function poppyTableStatements() { const t = POPPY_TABLES; return [ `CREATE TABLE IF NOT EXISTS "${t.conversations}" ( "id" VARCHAR(255) PRIMARY KEY NOT NULL, "client_id" VARCHAR(2048) NOT NULL, "user_id" VARCHAR(255) NOT NULL, "account_ref" VARCHAR(255) NULL, "agent_name" VARCHAR(255) NOT NULL, "thread_id" VARCHAR(255) NULL, "parent_id" VARCHAR(255) NULL, "open_direct_id" VARCHAR(255) NULL, "status" VARCHAR(16) NOT NULL, "responder" VARCHAR(16) NOT NULL, "context" TEXT NULL, "active_run_id" VARCHAR(255) NULL, "turn_seq" BIGINT NOT NULL, "grant_info" TEXT NULL, "reader_holder" VARCHAR(255) NULL, "reader_until" BIGINT NULL, "pruned_seq" BIGINT NOT NULL, "created_at" BIGINT NOT NULL, "updated_at" BIGINT NOT NULL )`, `CREATE TABLE IF NOT EXISTS "${t.events}" ( "conversation_id" VARCHAR(255) NOT NULL, "seq" BIGINT NOT NULL, "id" VARCHAR(64) NOT NULL, "type" VARCHAR(64) NOT NULL, "event" TEXT NOT NULL, "event_key" VARCHAR(255) NULL, "created_at" BIGINT NOT NULL, PRIMARY KEY ("conversation_id", "seq") )`, `CREATE TABLE IF NOT EXISTS "${t.messages}" ( "owner" VARCHAR(64) NOT NULL, "message_id" VARCHAR(256) NOT NULL, "fingerprint" VARCHAR(64) NOT NULL, "conversation_id" VARCHAR(255) NOT NULL, "response" TEXT NOT NULL, "created_at" BIGINT NOT NULL, PRIMARY KEY ("owner", "message_id") )`, ]; } /** Create the Poppy tables if missing. Idempotent. */ export async function ensurePoppyTables(db) { const mysql = isMySql(db); for (const statement of poppyTableStatements()) { await db.rawQuery(forDialect(statement, mysql)); } } function toInt(value) { if (typeof value === 'number') return value; if (typeof value === 'bigint') return Number(value); if (typeof value === 'string') return Number.parseInt(value, 10) || 0; return 0; } const nullable = (value) => (value == null ? null : String(value)); const PATCH_COLUMNS = { accountRef: 'account_ref', threadId: 'thread_id', openDirectId: 'open_direct_id', status: 'status', responder: 'responder', context: 'context', activeRunId: 'active_run_id', turnSeq: 'turn_seq', grant: 'grant_info', prunedSeq: 'pruned_seq', }; /** {@link PoppyStore} over Lucid (any of SQLite / Postgres / MySQL). */ export class LucidPoppyStore { db; #ready = null; #autoCreate; constructor(db, options = {}) { this.db = db; this.#autoCreate = options.autoCreateTables ?? true; } /** Provision the tables (once). A failure is retried on the next call. */ ready() { if (!this.#autoCreate) return Promise.resolve(); if (this.#ready === null) { this.#ready = ensurePoppyTables(this.db); this.#ready.catch(() => { this.#ready = null; }); } return this.#ready; } async createConversation(record) { await this.ready(); await this.db.table(POPPY_TABLES.conversations).insert({ id: record.id, client_id: record.clientId, user_id: record.userId, account_ref: record.accountRef, agent_name: record.agentName, thread_id: record.threadId, parent_id: record.parentId, open_direct_id: record.openDirectId, status: record.status, responder: record.responder, context: JSON.stringify(record.context), active_run_id: record.activeRunId, turn_seq: record.turnSeq, grant_info: record.grant === null ? null : JSON.stringify(record.grant), reader_holder: null, reader_until: null, pruned_seq: record.prunedSeq, created_at: record.createdAt, updated_at: record.updatedAt, }); } async getConversation(id) { await this.ready(); const row = await this.db.from(POPPY_TABLES.conversations).where('id', id).first(); if (!row) return null; return { id: String(row.id), clientId: String(row.client_id), userId: String(row.user_id), accountRef: nullable(row.account_ref), agentName: String(row.agent_name), threadId: nullable(row.thread_id), parentId: nullable(row.parent_id), openDirectId: nullable(row.open_direct_id), status: String(row.status), responder: String(row.responder), context: row.context == null ? {} : JSON.parse(String(row.context)), activeRunId: nullable(row.active_run_id), turnSeq: toInt(row.turn_seq), grant: row.grant_info == null ? null : JSON.parse(String(row.grant_info)), prunedSeq: toInt(row.pruned_seq), createdAt: toInt(row.created_at), updatedAt: toInt(row.updated_at), }; } async updateConversation(id, patch) { await this.ready(); const row = { updated_at: Date.now() }; for (const [key, column] of Object.entries(PATCH_COLUMNS)) { const value = patch[key]; if (value === undefined) continue; row[column] = key === 'context' || (key === 'grant' && value !== null) ? JSON.stringify(value) : value; } await this.db.from(POPPY_TABLES.conversations).where('id', id).update(row); } async appendEvent(conversationId, body, options = {}) { await this.ready(); for (let attempt = 0;; attempt++) { if (options.key !== undefined) { const existing = await this.db .from(POPPY_TABLES.events) .where('conversation_id', conversationId) .where('event_key', options.key) .first(); if (existing) { return { event: { seq: toInt(existing.seq), event: JSON.parse(String(existing.event)), }, created: false, }; } } const last = await this.db .from(POPPY_TABLES.events) .where('conversation_id', conversationId) .orderBy('seq', 'desc') .limit(1) .first(); const floor = last === null ? ((await this.getConversation(conversationId))?.prunedSeq ?? 0) : toInt(last.seq); const now = Date.now(); const stored = toEvent(floor + 1, body, now); try { await this.db.table(POPPY_TABLES.events).insert({ conversation_id: conversationId, seq: stored.seq, id: stored.event.id, type: body.type, event: JSON.stringify(stored.event), event_key: options.key ?? null, created_at: now, }); return { event: stored, created: true }; } catch (error) { // Another append took this position first: number this one after it. if (attempt >= 20) throw error; } } } async listEvents(conversationId, afterSeq, limit) { await this.ready(); const rows = await this.db .from(POPPY_TABLES.events) .where('conversation_id', conversationId) .where('seq', '>', afterSeq) .orderBy('seq', 'asc') .limit(limit) .select('seq', 'event'); return rows.map((row) => ({ seq: toInt(row.seq), event: JSON.parse(String(row.event)), })); } async findEventSeq(conversationId, eventId) { await this.ready(); const row = await this.db .from(POPPY_TABLES.events) .where('conversation_id', conversationId) .where('id', eventId) .first(); return row ? toInt(row.seq) : null; } async pruneEvents(conversationId, before) { await this.ready(); const last = await this.db .from(POPPY_TABLES.events) .where('conversation_id', conversationId) .orderBy('seq', 'desc') .limit(1) .first(); if (!last) return; const dropped = await this.db .from(POPPY_TABLES.events) .where('conversation_id', conversationId) .where('created_at', '<', before) .where('seq', '<', toInt(last.seq)) .orderBy('seq', 'desc') .limit(1) .first(); if (!dropped) return; const upTo = toInt(dropped.seq); await this.db .from(POPPY_TABLES.events) .where('conversation_id', conversationId) .where('seq', '<=', upTo) .delete(); const conversation = await this.getConversation(conversationId); if (conversation && conversation.prunedSeq < upTo) { await this.updateConversation(conversationId, { prunedSeq: upTo }); } } async claimMessage(owner, messageId, receipt) { await this.ready(); try { await this.db.table(POPPY_TABLES.messages).insert({ owner, message_id: messageId, fingerprint: receipt.fingerprint, conversation_id: receipt.conversationId, response: JSON.stringify(receipt.response), created_at: Date.now(), }); return { status: 'claimed' }; } catch (error) { const row = await this.db .from(POPPY_TABLES.messages) .where('owner', owner) .where('message_id', messageId) .first(); if (!row) throw error; return { status: 'existing', fingerprint: String(row.fingerprint), conversationId: String(row.conversation_id), response: JSON.parse(String(row.response)), }; } } async releaseMessage(owner, messageId) { await this.ready(); await this.db .from(POPPY_TABLES.messages) .where('owner', owner) .where('message_id', messageId) .delete(); } async leaseReader(conversationId, holder, ttlMs) { await this.ready(); const now = Date.now(); const row = await this.db.from(POPPY_TABLES.conversations).where('id', conversationId).first(); if (!row) return false; const current = nullable(row.reader_holder); if (current !== null && current !== holder && toInt(row.reader_until) > now) return false; // Compare-and-set on what was read: of two takers, the second matches nothing. let query = this.db.from(POPPY_TABLES.conversations).where('id', conversationId); query = current === null ? query.whereNull('reader_holder') : query.where('reader_holder', current).where('reader_until', toInt(row.reader_until)); await query.update({ reader_holder: holder, reader_until: now + ttlMs }); const after = await this.db .from(POPPY_TABLES.conversations) .where('id', conversationId) .first(); return nullable(after?.reader_holder) === holder; } async releaseReader(conversationId, holder) { await this.ready(); await this.db .from(POPPY_TABLES.conversations) .where('id', conversationId) .where('reader_holder', holder) .update({ reader_holder: null, reader_until: null }); } } //# sourceMappingURL=store.js.map