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