@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
331 lines • 15.3 kB
JavaScript
import { AGENT_TABLES, ensureStreamFrameTable, rowsOf } from './stores/lucid-schema.js';
import { portableSql } from './stores/sql-dialect.js';
/** Default TTL (seconds) for a run's rows: 1h past its last write, the same window the Redis sink keeps. */
const DEFAULT_TTL_SECONDS = 3600;
/** Default gap (ms) between two reads of a run a subscriber is following. */
const DEFAULT_POLL_INTERVAL_MS = 250;
/** Default gap (ms) between two reads once a run has been quiet for {@link IDLE_AFTER_MS}. */
const DEFAULT_IDLE_POLL_INTERVAL_MS = 1000;
/** How long a run has to write nothing before its subscribers slow down to the idle interval. */
const IDLE_AFTER_MS = 5000;
/** Default window (ms) consecutive `text` frames are gathered over before they are written as one row. */
const DEFAULT_FLUSH_MS = 50;
/** Rows read per query, so a long run's replay is paged rather than loaded whole. */
const READ_PAGE = 500;
/** Run ids deleted per statement by {@link LucidTokenStreamSink.purgeExpired}. */
const PURGE_CHUNK = 200;
/** The least time between two purges an instance starts on its own (see `autoPurge`). */
const AUTO_PURGE_EVERY_MS = 60_000;
/** Attempts at one append before a sequence-number collision is reported instead of retried. */
const APPEND_ATTEMPTS = 8;
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;
}
/**
* Did this insert lose the race for a `(run_id, seq)`? Read off the driver's own code — Postgres
* `23505`, SQLite `SQLITE_CONSTRAINT_*`, MySQL `ER_DUP_ENTRY`/1062 (or its deadlock victim, 1213),
* SQL Server 2627 — so nothing
* else (a missing table, a lost connection) is ever retried as though it were one.
*/
function isUniqueViolation(error) {
const e = error;
if (e === null || typeof e !== 'object')
return false;
if (e.code === '23505' || e.code === 'ER_DUP_ENTRY')
return true;
// InnoDB settles two `INSERT … SELECT MAX(seq) + 1` into one run (both taking gap locks on the
// same range) by rolling one back as a deadlock victim, not with a duplicate key: the same lost
// race, just as safe to retry.
if (e.code === 'ER_LOCK_DEADLOCK' || e.errno === 1213)
return true;
if (typeof e.code === 'string' && e.code.startsWith('SQLITE_CONSTRAINT'))
return true;
return e.errno === 1062 || e.number === 2627;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* A multi-replica {@link TokenStreamSink} over the app's SQL database (through `@adonisjs/lucid`) —
* for a deployment with several replicas and NO Redis. The replica running the model turn appends
* each frame as a row of `agent_stream_frame`; any replica serves the run's SSE by reading the rows
* past its cursor, in order, until it meets the end marker. A subscriber that attaches late, or
* after the run ended, replays from the first row — the same guarantee the in-process and Redis
* sinks give.
*
* Two things differ from the Redis sink, and both are the price of having no broker:
*
* - **Delivery is polled.** There is no publish to wake a subscriber, so a frame waits up to
* `pollIntervalMs` (250 ms) before a browser sees it.
* - **Text is coalesced on the way in.** Consecutive `text` frames written within `flushMs`
* (50 ms) are stored as ONE `text` frame holding their concatenation. The stored rows are the
* run's stream: every subscriber, on every replica, reads the same rows in the same order, so
* the SSE event ids `AgentSseEncoder` counts from them — and an `after` cursor — stay exact. They
* are NOT the ids the in-process sink would have produced for the same run (it numbers every
* token), which only matters to a cursor carried across a change of sink.
*
* Sequence numbers are per run, start at 1 and have no gaps: each append takes `MAX(seq) + 1` for
* its run in the same statement that inserts it, and the `(run_id, seq)` primary key turns a
* collision into a retry. A run normally has ONE writer, and then no append ever collides; the
* retry exists for a parent whose delegated children (each forwarding into its stream, possibly
* from another replica) write at once. Nothing depends on ordering across runs.
*
* Text a writer is still gathering lives in the process for at most `flushMs`; a process that dies
* in that window loses it from the LIVE stream (the persisted message is unaffected).
*
* Wire it with `defineConfig({ sink: tokenSinks.lucid() })`.
*/
export class LucidTokenStreamSink {
db;
table;
pollIntervalMs;
idlePollIntervalMs;
flushMs;
ttlSeconds;
autoPurge;
autoCreateTables;
writes = new Map();
provisioned;
lastAutoPurge = 0;
constructor(db, options = {}) {
this.db = db;
const table = options.tableName ?? AGENT_TABLES.streamFrames;
// The name is spliced into SQL (DDL takes no bindings), so it has to be a plain identifier.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) {
throw new Error(`LucidTokenStreamSink: "${table}" is not a valid table name`);
}
this.table = table;
this.pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
this.idlePollIntervalMs = Math.max(this.pollIntervalMs, options.idlePollIntervalMs ?? DEFAULT_IDLE_POLL_INTERVAL_MS);
this.flushMs = Math.max(0, options.flushMs ?? DEFAULT_FLUSH_MS);
this.ttlSeconds = options.ttlSeconds ?? DEFAULT_TTL_SECONDS;
this.autoPurge = options.autoPurge ?? true;
this.autoCreateTables = options.autoCreateTables ?? true;
}
/**
* Create the frame table if it is missing. The agent provider calls this once as the app starts;
* a sink built by hand falls back to it on first use. A no-op under `autoCreateTables: false`.
*/
ensureSchema() {
if (!this.autoCreateTables)
return Promise.resolve();
if (this.provisioned === undefined) {
this.provisioned = ensureStreamFrameTable(this.db, this.table).catch((error) => {
this.provisioned = undefined;
throw error;
});
}
return this.provisioned;
}
runWrites(runId) {
let state = this.writes.get(runId);
if (state === undefined) {
state = {
text: null,
timer: undefined,
tail: Promise.resolve(),
pending: 0,
failure: undefined,
};
this.writes.set(runId, state);
}
return state;
}
/** Forget a run's write state once nothing is gathered, queued or left to report. */
release(runId, state) {
if (state.pending === 0 &&
state.text === null &&
state.failure === undefined &&
this.writes.get(runId) === state) {
this.writes.delete(runId);
}
}
/** Queue `work` behind every write already queued for the run, so rows land in call order. */
enqueue(runId, state, work) {
state.pending += 1;
const next = state.tail.then(work);
state.tail = next
.then(() => undefined, () => undefined)
.then(() => {
state.pending -= 1;
this.release(runId, state);
});
return next;
}
/** Queue the gathered text (if any) as one `text` row. */
flushText(runId, state) {
if (state.timer !== undefined) {
clearTimeout(state.timer);
state.timer = undefined;
}
if (state.text === null)
return Promise.resolve();
const frame = { t: 'text', v: state.text };
state.text = null;
return this.enqueue(runId, state, () => this.append(runId, JSON.stringify(frame)));
}
/** Raise, once, a gathered write that failed with nobody awaiting it. */
raiseFailure(state) {
const failure = state.failure;
if (failure !== undefined) {
state.failure = undefined;
throw failure.error;
}
}
/** Append one row to the run — a frame, or the end marker (`null`). */
async append(runId, frame) {
await this.ensureSchema();
for (let attempt = 1;; attempt += 1) {
try {
// The next number and the insert are one statement; see the class comment for why the
// primary key, not a lock, is what settles two writers asking at once.
await this.db.rawQuery(portableSql(this.db, `INSERT INTO "${this.table}" ("run_id", "seq", "frame", "created_at") ` +
`SELECT ?, COALESCE(MAX("seq"), 0) + 1, ?, ? FROM "${this.table}" WHERE "run_id" = ?`), [runId, frame, Date.now(), runId]);
return;
}
catch (error) {
if (attempt >= APPEND_ATTEMPTS || !isUniqueViolation(error))
throw error;
}
}
}
/**
* The run's writer. Writers opened for the same run in one process share what they are gathering,
* so a delegated run's text (forwarded through a {@link childSinkWriter}) and its parent's next
* frame keep the order they were written in.
*/
open(runId) {
return {
write: async (frame) => {
const state = this.runWrites(runId);
this.raiseFailure(state);
if (frame.t === 'text' && this.flushMs > 0) {
state.text = (state.text ?? '') + frame.v;
// Counted from the FIRST gathered token, not the last: a steady stream still reaches the
// table every `flushMs`, instead of being held for as long as the model keeps talking.
if (state.timer === undefined) {
state.timer = setTimeout(() => {
state.timer = undefined;
this.flushText(runId, state).catch((error) => {
state.failure = { error };
});
}, this.flushMs);
}
return;
}
const flushed = this.flushText(runId, state);
const appended = this.enqueue(runId, state, () => this.append(runId, JSON.stringify(frame)));
await Promise.all([flushed, appended]);
},
flush: async () => {
const state = this.runWrites(runId);
const flushed = this.flushText(runId, state);
await Promise.all([flushed, state.tail]);
this.raiseFailure(state);
this.release(runId, state);
},
end: async () => {
const state = this.runWrites(runId);
const flushed = this.flushText(runId, state);
const ended = this.enqueue(runId, state, () => this.append(runId, null));
await Promise.all([flushed, ended]);
this.raiseFailure(state);
this.release(runId, state);
this.purgeInBackground();
},
};
}
async *subscribe(runId) {
await this.ensureSchema();
let cursor = 0;
let quietSince = Date.now();
while (true) {
const rows = await this.db
.from(this.table)
.where('run_id', runId)
.where('seq', '>', cursor)
.orderBy('seq', 'asc')
.limit(READ_PAGE)
.select('seq', 'frame');
let ended = false;
for (const row of rows) {
cursor = toInt(row.seq);
if (row.frame === null || row.frame === undefined) {
ended = true;
continue;
}
yield JSON.parse(String(row.frame));
}
if (ended) {
return;
}
if (rows.length > 0) {
quietSince = Date.now();
// A full page means there may be more already written: read on without waiting.
if (rows.length >= READ_PAGE)
continue;
}
await sleep(Date.now() - quietSince >= IDLE_AFTER_MS ? this.idlePollIntervalMs : this.pollIntervalMs);
}
}
async has(runId) {
await this.ensureSchema();
return (await this.db.from(this.table).where('run_id', runId).first()) !== null;
}
async close(runId) {
const state = this.writes.get(runId);
if (state !== undefined) {
if (state.timer !== undefined)
clearTimeout(state.timer);
state.timer = undefined;
state.text = null;
state.failure = undefined;
await state.tail;
this.writes.delete(runId);
}
await this.ensureSchema();
await this.db.from(this.table).where('run_id', runId).delete();
}
/**
* Delete every run whose last write is older than `ttlSeconds` at `now` (epoch ms) — ended runs
* past their replay window and runs that crashed without ending alike. Returns how many runs went.
* Safe to call from any replica, and from several at once. A no-op under `ttlSeconds: 0`.
*
* An instance already calls it after a run ends (at most once a minute; `autoPurge`), which keeps
* the table bounded on a replica that streams. Call it from a schedule as well if a quiet
* deployment must not keep the last hour's streams around indefinitely.
*/
async purgeExpired(now = Date.now()) {
if (this.ttlSeconds <= 0)
return 0;
await this.ensureSchema();
const cutoff = now - this.ttlSeconds * 1000;
// Two steps rather than one `DELETE … WHERE run_id IN (SELECT … FROM the same table)`, which
// MySQL refuses. A run that writes between the two was idle for the whole TTL a moment ago.
const lapsed = rowsOf(await this.db.rawQuery(portableSql(this.db, `SELECT "run_id" AS run_id FROM "${this.table}" GROUP BY "run_id" HAVING MAX("created_at") < ?`), [cutoff])).map((row) => String(row.run_id));
for (let index = 0; index < lapsed.length; index += PURGE_CHUNK) {
await this.db
.from(this.table)
.whereIn('run_id', lapsed.slice(index, index + PURGE_CHUNK))
.delete();
}
return lapsed.length;
}
/** {@link purgeExpired}, unawaited and at most once a minute. Its failure is not the run's. */
purgeInBackground() {
if (!this.autoPurge || this.ttlSeconds <= 0)
return;
const now = Date.now();
if (now - this.lastAutoPurge < AUTO_PURGE_EVERY_MS)
return;
this.lastAutoPurge = now;
this.purgeExpired(now).catch(() => {
// Retried after the next run to end; a purge that cannot run must not fail a finished turn.
});
}
}
//# sourceMappingURL=lucid-token-stream-sink.js.map