@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
700 lines • 30.8 kB
JavaScript
import { summarizeGenuiCalls } from '../genui-telemetry.js';
import { summarizeLatency, summarizeRunMetrics } from '../run-metrics.js';
import { estimateCost, resolveModelPrice } from '../spi/pricing-store.js';
import { inAppendOrder, toolCallIdOfRow } from './lucid.js';
import { AGENT_TABLES, ensureAgentTables } from './lucid-schema.js';
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;
}
/** A stored `agent_genui_call` row as the aggregation reads it (NULL → absent). */
function genuiCallRow(row) {
const text = (value) => value === null || value === undefined ? undefined : String(value);
const num = (value) => {
const parsed = toNumOrNull(value);
return parsed === null ? undefined : parsed;
};
const agentName = text(row.agent_name);
const errorCategory = text(row.error_category);
const errorMessage = text(row.error_message);
const costUsd = num(row.cost_usd);
const costSource = text(row.cost_source);
const firstFrameMs = num(row.first_frame_ms);
const completeFrameMs = num(row.complete_frame_ms);
const payload = text(row.payload);
return {
toolCallId: String(row.id),
runId: String(row.run_id),
threadId: String(row.thread_id),
actorRef: String(row.actor_ref),
...(agentName !== undefined ? { agentName } : {}),
step: Number(row.step),
toolName: String(row.tool_name),
channel: String(row.channel),
mode: String(row.mode),
modelId: String(row.model_id),
attempt: Number(row.attempt),
outcome: String(row.outcome),
validity: String(row.validity),
...(errorCategory !== undefined
? { errorCategory: errorCategory }
: {}),
...(errorMessage !== undefined ? { errorMessage } : {}),
inputTokens: Number(row.input_tokens),
outputTokens: Number(row.output_tokens),
...(costUsd !== undefined ? { costUsd } : {}),
...(costSource !== undefined ? { costSource: costSource } : {}),
...(firstFrameMs !== undefined ? { firstFrameMs } : {}),
...(completeFrameMs !== undefined ? { completeFrameMs } : {}),
payloadBytes: Number(row.payload_bytes),
...(payload !== undefined ? { payload } : {}),
createdAt: new Date(Number(row.created_at)),
};
}
function toNumOrNull(value) {
if (value === null || value === undefined)
return null;
if (typeof value === 'number')
return value;
if (typeof value === 'bigint')
return Number(value);
if (typeof value === 'string') {
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}
function toStrOrNull(value) {
return value === null || value === undefined ? null : String(value);
}
/** Parse a TEXT JSON column back to a value; `undefined` for null/empty/malformed. */
function parseJsonCol(text) {
if (typeof text !== 'string' || text.length === 0)
return undefined;
try {
return JSON.parse(text);
}
catch {
return undefined;
}
}
/** Default page size + hard cap, mirroring the governance routes' clamp. */
function clampLimit(limit, fallback = 50) {
const value = limit !== undefined && Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : fallback;
return Math.min(value, 200);
}
/** Optional inclusive UTC-day bounds → epoch-ms; each side `undefined` when unbounded. */
function optionalDayBounds(range) {
return {
start: range?.from !== undefined ? Date.parse(`${range.from}T00:00:00.000Z`) : undefined,
end: range?.to !== undefined ? Date.parse(`${range.to}T23:59:59.999Z`) : undefined,
};
}
/** Map a raw `agent_run` Lucid row to the read-model {@link RunSummaryRow}. */
function runRowToSummary(row) {
const startedAt = toInt(row.started_at);
const finishedAtRaw = row.finished_at;
const finishedAt = finishedAtRaw === null || finishedAtRaw === undefined ? null : toInt(finishedAtRaw);
return {
runId: String(row.id),
threadId: String(row.thread_id),
actorRef: String(row.actor_ref),
tenantRef: row.tenant_ref === null || row.tenant_ref === undefined ? null : String(row.tenant_ref),
agentName: row.agent_name === null || row.agent_name === undefined ? null : String(row.agent_name),
parentRunId: row.parent_run_id === null || row.parent_run_id === undefined
? null
: String(row.parent_run_id),
status: String(row.status),
startedAt: new Date(startedAt).toISOString(),
finishedAt: finishedAt === null ? null : new Date(finishedAt).toISOString(),
durationMs: finishedAt === null ? null : finishedAt - startedAt,
stepCount: toInt(row.step_count),
inputTokens: toInt(row.input_tokens),
outputTokens: toInt(row.output_tokens),
costUsd: toNumOrNull(row.cost_usd),
error: row.error === null || row.error === undefined ? null : String(row.error),
durable: toInt(row.durable) !== 0,
ttftMs: toNumOrNull(row.ttft_ms),
errorCode: toStrOrNull(row.error_code),
modelId: toStrOrNull(row.model_id),
};
}
/** Map a raw `agent_tool_call` Lucid row to the read-model {@link RunToolCallRow}. */
function toolCallRowToDetail(row) {
return {
toolCallId: toolCallIdOfRow(row),
toolName: String(row.tool_name),
toolType: String(row.tool_type),
status: String(row.status),
input: parseJsonCol(row.input),
output: parseJsonCol(row.output),
error: row.error === null || row.error === undefined ? null : String(row.error),
executionMs: toNumOrNull(row.execution_ms),
createdAt: new Date(toInt(row.created_at)).toISOString(),
};
}
function rowToUsage(row) {
return {
modelId: String(row.model_id),
actorRef: String(row.actor_ref),
threadId: String(row.thread_id),
day: new Date(toInt(row.created_at)).toISOString().slice(0, 10),
inputTokens: toInt(row.input_tokens),
outputTokens: toInt(row.output_tokens),
cacheWriteTokens: toNumOrNull(row.cache_write_tokens),
cacheReadTokens: toNumOrNull(row.cache_read_tokens),
costUsd: toNumOrNull(row.cost_usd),
};
}
/**
* A production {@link AgentGovernanceQueries} — the read/analytics half of the store SPI — backed by
* AdonisJS **Lucid** (Knex) over the same five agent tables {@link import('./lucid.js').LucidAgentStore}
* writes. Like the store it touches only the structural {@link LucidDatabaseLike} slice, so
* `@adonisjs/lucid` stays an *optional peer* (the factory casts the real `db` in).
*
* Cost is the token ledger priced against the current prices from the injected {@link AgentPricingStore}
* (fetched once per query, exactly like the Drizzle/MikroORM references and the loop's cost fold), so a
* host that binds its own pricing store controls the cost every governance surface reports. A
* provider-reported `cost_usd` on a usage row always wins; an unpriced model contributes 0 cost (its
* tokens still count). Omit the pricing store entirely for a zero-cost read-model. Aggregation is
* in-process (like `quotaToday`) so UTC-day bucketing stays dialect-portable across SQLite/Postgres/MySQL
* — the behavioral twin of {@link import('../testing/in-memory-governance-queries.js').InMemoryGovernanceQueries}.
*/
export class LucidGovernanceQueries {
db;
pricingStore;
autoCreateTables;
constructor(db, pricingStore, options = {}) {
this.db = db;
this.pricingStore = pricingStore;
this.autoCreateTables = options.autoCreateTables ?? true;
}
/** Provision the shared schema on first use (no-op when disabled), memoized across the stores. */
ready() {
return this.autoCreateTables ? ensureAgentTables(this.db) : Promise.resolve();
}
/** Provision the shared schema now (what the agent provider calls at app start). Idempotent. */
ensureSchema() {
return this.ready();
}
async loadPricing() {
const pricing = new Map();
if (this.pricingStore === undefined)
return pricing;
for (const price of await this.pricingStore.listCurrentPrices()) {
pricing.set(price.modelId, price);
}
return pricing;
}
/** Provider-reported cost wins per row; otherwise the cache-aware token estimate (0 when unpriced). */
rowCost(row, pricing) {
if (row.costUsd !== null)
return row.costUsd;
const price = resolveModelPrice(pricing, row.modelId);
if (price === undefined)
return 0;
return estimateCost({
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
...(row.cacheWriteTokens !== null ? { cacheWriteTokens: row.cacheWriteTokens } : {}),
...(row.cacheReadTokens !== null ? { cacheReadTokens: row.cacheReadTokens } : {}),
}, price);
}
/** The usage ledger rows whose `created_at` falls inside the inclusive UTC-day range. */
async usageInRange(range) {
const start = Date.parse(`${range.fromDay}T00:00:00.000Z`);
const end = Date.parse(`${range.toDay}T23:59:59.999Z`);
const rows = await this.db
.from(AGENT_TABLES.tokenUsage)
.where('created_at', '>=', start)
.where('created_at', '<=', end)
.select('*');
return rows.map(rowToUsage);
}
async spendByModel(range) {
await this.ready();
const pricing = await this.loadPricing();
const byModel = new Map();
for (const row of await this.usageInRange(range)) {
const bucket = byModel.get(row.modelId) ?? {
requests: 0,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
};
bucket.requests += 1;
bucket.inputTokens += row.inputTokens;
bucket.outputTokens += row.outputTokens;
bucket.costUsd += this.rowCost(row, pricing);
byModel.set(row.modelId, bucket);
}
const result = [];
for (const [modelId, bucket] of byModel) {
result.push({ modelId, ...bucket });
}
result.sort((left, right) => right.costUsd - left.costUsd || left.modelId.localeCompare(right.modelId));
return result;
}
async spendByActor(range) {
await this.ready();
const pricing = await this.loadPricing();
const byActor = new Map();
for (const row of await this.usageInRange(range)) {
const bucket = byActor.get(row.actorRef) ?? { requests: 0, totalTokens: 0, costUsd: 0 };
bucket.requests += 1;
bucket.totalTokens += row.inputTokens + row.outputTokens;
bucket.costUsd += this.rowCost(row, pricing);
byActor.set(row.actorRef, bucket);
}
const result = [];
for (const [actorRef, bucket] of byActor) {
result.push({ actorRef, ...bucket });
}
result.sort((left, right) => right.costUsd - left.costUsd || left.actorRef.localeCompare(right.actorRef));
return result;
}
async usageTrend(range) {
await this.ready();
const pricing = await this.loadPricing();
const byDay = new Map();
for (const row of await this.usageInRange(range)) {
const bucket = byDay.get(row.day) ?? { totalTokens: 0, costUsd: 0 };
bucket.totalTokens += row.inputTokens + row.outputTokens;
bucket.costUsd += this.rowCost(row, pricing);
byDay.set(row.day, bucket);
}
const result = [];
for (const [day, bucket] of byDay) {
result.push({ day, totalTokens: bucket.totalTokens, costUsd: bucket.costUsd });
}
result.sort((left, right) => left.day.localeCompare(right.day));
return result;
}
/**
* Newest-first recent tool calls, capped at `limit`. Resolves each call's `threadId` through its
* owning message (the structural query slice has no `join`, so it's one lookup per call — bounded by
* `limit`, mirroring the reference adapters' N+1 for the recent feeds).
*/
async recentToolCalls(limit) {
await this.ready();
const calls = await this.db
.from(AGENT_TABLES.toolCalls)
.orderBy('created_at', 'desc')
.limit(limit)
.select('*');
const result = [];
for (const call of calls) {
const message = await this.db
.from(AGENT_TABLES.messages)
.where('id', String(call.message_id))
.first();
result.push({
toolCallId: toolCallIdOfRow(call),
toolName: String(call.tool_name),
toolType: String(call.tool_type),
status: String(call.status),
threadId: message === null || message === undefined ? '' : String(message.thread_id),
createdAt: new Date(toInt(call.created_at)).toISOString(),
});
}
return result;
}
/**
* Newest-first recent threads (soft-deleted excluded, mirroring `listThreads`/the reference), each
* with its message count and rolled-up token total, capped at `limit`.
*/
async recentThreads(limit) {
await this.ready();
const threads = await this.db
.from(AGENT_TABLES.threads)
.whereNull('deleted_at')
.orderBy('updated_at', 'desc')
.limit(limit)
.select('*');
const result = [];
for (const thread of threads) {
const threadId = String(thread.id);
const messages = await this.db
.from(AGENT_TABLES.messages)
.where('thread_id', threadId)
.select('id');
const usageRows = await this.db
.from(AGENT_TABLES.tokenUsage)
.where('thread_id', threadId)
.select('input_tokens', 'output_tokens');
const totalTokens = usageRows.reduce((sum, row) => sum + toInt(row.input_tokens) + toInt(row.output_tokens), 0);
result.push({
threadId,
title: String(thread.title),
actorRef: String(thread.actor_ref),
messageCount: messages.length,
totalTokens,
lastActivityAt: new Date(toInt(thread.updated_at)).toISOString(),
});
}
return result;
}
// ── Run lifecycle read-model ───────────────────────────────────────────────
/**
* Filterable, cursor-paginated run list, newest-first (`started_at` desc, then `id` desc for a
* stable tiebreak). `after` is an opaque cursor (over the SAME filter set); a page fetches
* `first + 1` rows to learn whether a next page exists. A store without recorded runs yields an
* empty page.
*
* Forward-only, so the returned {@link CursorPage} always carries `prevCursor: null` /
* `hasPrev: false` — see `src/pagination.ts`.
*/
async listRuns(filter = {}) {
await this.ready();
const limit = clampLimit(filter.first);
const offset = (() => {
const parsed = filter.after !== undefined ? Number.parseInt(filter.after, 10) : 0;
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
})();
let query = this.db.from(AGENT_TABLES.runs);
if (filter.actor !== undefined)
query = query.where('actor_ref', filter.actor);
if (filter.agent !== undefined)
query = query.where('agent_name', filter.agent);
if (filter.status !== undefined)
query = query.where('status', filter.status);
if (filter.from !== undefined) {
query = query.where('started_at', '>=', Date.parse(`${filter.from}T00:00:00.000Z`));
}
if (filter.to !== undefined) {
query = query.where('started_at', '<=', Date.parse(`${filter.to}T23:59:59.999Z`));
}
const rows = await query
.orderBy('started_at', 'desc')
.orderBy('id', 'desc')
.limit(limit + 1)
.offset(offset)
.select('*');
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
return {
items: page.map(runRowToSummary),
nextCursor: hasMore ? String(offset + limit) : null,
prevCursor: null,
hasNext: hasMore,
hasPrev: false,
};
}
/**
* The full trace of one run: the run row plus every message / tool call / usage row stamped with
* its `run_id` (oldest first), and the subset of tool calls still `pending_approval`. `null` when
* the run is unknown.
*/
async runDetail(runId) {
await this.ready();
const runRow = await this.db.from(AGENT_TABLES.runs).where('id', runId).first();
if (runRow === null || runRow === undefined)
return null;
const messageRows = await inAppendOrder(this.db.from(AGENT_TABLES.messages).where('run_id', runId), 'asc').select('*');
const toolCallRows = await this.db
.from(AGENT_TABLES.toolCalls)
.where('run_id', runId)
.orderBy('created_at', 'asc')
.select('*');
const usageRows = await this.db
.from(AGENT_TABLES.tokenUsage)
.where('run_id', runId)
.orderBy('created_at', 'asc')
.select('*');
const messages = messageRows.map((row) => ({
id: String(row.id),
role: String(row.role),
content: String(row.content),
createdAt: new Date(toInt(row.created_at)).toISOString(),
}));
const toolCalls = toolCallRows.map(toolCallRowToDetail);
const usage = usageRows.map((row) => ({
modelId: String(row.model_id),
purpose: String(row.purpose),
inputTokens: toInt(row.input_tokens),
outputTokens: toInt(row.output_tokens),
costUsd: toNumOrNull(row.cost_usd),
createdAt: new Date(toInt(row.created_at)).toISOString(),
}));
return {
run: runRowToSummary(runRow),
messages,
toolCalls,
approvals: toolCalls.filter((call) => call.status === 'pending_approval'),
usage,
};
}
/**
* Tool calls sitting `pending_approval`, oldest first (an inbox drains from the back). Each call's
* owning actor is resolved through its run (`run_id → agent_run.actor_ref`), falling back to its
* thread (`message → agent_thread.actor_ref`) for a call recorded before run tracking. The `actor`
* filter is applied after resolution; `limit` caps the returned inbox.
*/
async pendingApprovals(filter = {}) {
await this.ready();
const limit = clampLimit(filter.limit);
const calls = await this.db
.from(AGENT_TABLES.toolCalls)
.where('status', 'pending_approval')
.orderBy('created_at', 'asc')
.select('*');
const result = [];
for (const call of calls) {
const message = await this.db
.from(AGENT_TABLES.messages)
.where('id', String(call.message_id))
.first();
const threadId = message === null || message === undefined ? '' : String(message.thread_id);
const runId = call.run_id !== null && call.run_id !== undefined
? String(call.run_id)
: message?.run_id !== null && message?.run_id !== undefined
? String(message.run_id)
: null;
let actorRef = '';
if (runId !== null) {
const run = await this.db.from(AGENT_TABLES.runs).where('id', runId).first();
if (run !== null && run !== undefined)
actorRef = String(run.actor_ref);
}
if (actorRef === '' && threadId !== '') {
const thread = await this.db.from(AGENT_TABLES.threads).where('id', threadId).first();
if (thread !== null && thread !== undefined)
actorRef = String(thread.actor_ref);
}
if (filter.actor !== undefined && actorRef !== filter.actor)
continue;
result.push({
toolCallId: toolCallIdOfRow(call),
toolName: String(call.tool_name),
input: parseJsonCol(call.input),
threadId,
runId,
actorRef,
requestedAt: new Date(toInt(call.created_at)).toISOString(),
});
if (result.length >= limit)
break;
}
return result;
}
/**
* Per-tool call/failure/rejection/latency rollup over the (optional) range, highest call count
* first. `avgDurationMs` is the mean `execution_ms` across calls that recorded one; `null` when
* none did.
*/
async genuiStats(range) {
await this.ready();
const start = Date.parse(`${range.fromDay}T00:00:00.000Z`);
const end = Date.parse(`${range.toDay}T23:59:59.999Z`);
const rows = await this.db
.from(AGENT_TABLES.genuiCalls)
.where('created_at', '>=', start)
.where('created_at', '<=', end)
.select('*');
return summarizeGenuiCalls(rows.map(genuiCallRow));
}
async perToolStats(range = {}) {
await this.ready();
const { start, end } = optionalDayBounds(range);
let query = this.db.from(AGENT_TABLES.toolCalls);
if (start !== undefined)
query = query.where('created_at', '>=', start);
if (end !== undefined)
query = query.where('created_at', '<=', end);
const calls = await query.select('*');
const byTool = new Map();
for (const call of calls) {
const toolName = String(call.tool_name);
const toolType = String(call.tool_type);
const key = `${toolName}