UNPKG

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