UNPKG

@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
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}${toolType}`; const bucket = byTool.get(key) ?? { toolName, toolType, calls: 0, failed: 0, rejected: 0, durations: [], }; bucket.calls += 1; if (String(call.status) === 'failed') bucket.failed += 1; if (String(call.status) === 'rejected') bucket.rejected += 1; const execMs = toNumOrNull(call.execution_ms); if (execMs !== null) bucket.durations.push(execMs); byTool.set(key, bucket); } const result = []; for (const bucket of byTool.values()) { const execution = summarizeLatency(bucket.durations); result.push({ toolName: bucket.toolName, toolType: bucket.toolType, calls: bucket.calls, failed: bucket.failed, rejected: bucket.rejected, avgDurationMs: bucket.durations.length === 0 ? null : bucket.durations.reduce((a, b) => a + b, 0) / bucket.durations.length, p50ExecutionMs: execution.p50Ms, p95ExecutionMs: execution.p95Ms, }); } result.sort((left, right) => right.calls - left.calls || left.toolName.localeCompare(right.toolName)); return result; } /** * Success / failure / cancel rates + mean settled-run duration over the (optional) range, bucketed * on `started_at`, plus a per-agent breakdown, a daily runs/failed trend, turn latency and failures * by code over the same rows. A store without recorded runs yields all zeros / `null` duration / * empty breakdown+trend+codes. */ async runReliability(range = {}) { await this.ready(); const { start, end } = optionalDayBounds(range); let query = this.db.from(AGENT_TABLES.runs); if (start !== undefined) query = query.where('started_at', '>=', start); if (end !== undefined) query = query.where('started_at', '<=', end); const runs = await query.select('*'); let completed = 0; let failed = 0; let cancelled = 0; let running = 0; let durationSum = 0; let durationCount = 0; const byAgent = new Map(); const byDay = new Map(); for (const run of runs) { const status = String(run.status); if (status === 'completed') completed += 1; else if (status === 'failed') failed += 1; else if (status === 'cancelled') cancelled += 1; else running += 1; const finishedAt = run.finished_at; if (status !== 'running' && finishedAt !== null && finishedAt !== undefined) { durationSum += toInt(finishedAt) - toInt(run.started_at); durationCount += 1; } const agentName = run.agent_name === null || run.agent_name === undefined ? null : String(run.agent_name); const agentBucket = byAgent.get(agentName) ?? { runs: 0, failed: 0, completed: 0 }; agentBucket.runs += 1; if (status === 'failed') agentBucket.failed += 1; if (status === 'completed') agentBucket.completed += 1; byAgent.set(agentName, agentBucket); const day = new Date(toInt(run.started_at)).toISOString().slice(0, 10); const dayBucket = byDay.get(day) ?? { runs: 0, failed: 0 }; dayBucket.runs += 1; if (status === 'failed') dayBucket.failed += 1; byDay.set(day, dayBucket); } const total = runs.length; const byAgentRows = [...byAgent.entries()] .map(([agentName, bucket]) => ({ agentName, runs: bucket.runs, failed: bucket.failed, successRate: bucket.runs === 0 ? 0 : bucket.completed / bucket.runs, })) .sort((left, right) => right.runs - left.runs || (left.agentName ?? '').localeCompare(right.agentName ?? '')); const trend = [...byDay.entries()] .map(([day, bucket]) => ({ day, runs: bucket.runs, failed: bucket.failed })) .sort((left, right) => left.day.localeCompare(right.day)); return { runs: total, completed, failed, cancelled, running, successRate: total === 0 ? 0 : completed / total, failureRate: total === 0 ? 0 : failed / total, cancelRate: total === 0 ? 0 : cancelled / total, avgDurationMs: durationCount === 0 ? null : durationSum / durationCount, byAgent: byAgentRows, trend, ...summarizeRunMetrics(runs.map((run) => ({ status: String(run.status), parentRunId: toStrOrNull(run.parent_run_id), startedAt: toInt(run.started_at), finishedAt: run.finished_at === null || run.finished_at === undefined ? null : toInt(run.finished_at), ttftMs: toNumOrNull(run.ttft_ms), errorCode: toStrOrNull(run.error_code), }))), }; } /** * The thread governance drill-down: metadata + a LIFETIME usage rollup (every usage row ever * recorded for the thread, not range-scoped) + its most recent runs/messages. `null` when the * thread is unknown. Soft-deleted threads still resolve (with `deleted: true`), mirroring * `runDetail`'s "the record exists, render it" posture. */ async threadDetail(threadId) { await this.ready(); const threadRow = await this.db.from(AGENT_TABLES.threads).where('id', threadId).first(); if (threadRow === null || threadRow === undefined) return null; const pricing = await this.loadPricing(); const usageRows = await this.db .from(AGENT_TABLES.tokenUsage) .where('thread_id', threadId) .select('*'); const usage = usageRows.map(rowToUsage); const totalTokens = usage.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0); const costUsd = usage.length === 0 ? null : usage.reduce((sum, row) => sum + this.rowCost(row, pricing), 0); const runRows = await this.db .from(AGENT_TABLES.runs) .where('thread_id', threadId) .orderBy('started_at', 'desc') .limit(20) .select('*'); const messageRows = await inAppendOrder(this.db.from(AGENT_TABLES.messages).where('thread_id', threadId), 'desc') .limit(50) .select('*'); const allRunIds = await this.db .from(AGENT_TABLES.runs) .where('thread_id', threadId) .select('id'); const allMessageIds = await this.db .from(AGENT_TABLES.messages) .where('thread_id', threadId) .select('id'); return { threadId: String(threadRow.id), title: String(threadRow.title), actorRef: String(threadRow.actor_ref), createdAt: new Date(toInt(threadRow.created_at)).toISOString(), updatedAt: new Date(toInt(threadRow.updated_at)).toISOString(), deleted: threadRow.deleted_at !== null && threadRow.deleted_at !== undefined, usage: { totalTokens, costUsd, runCount: allRunIds.length, messageCount: allMessageIds.length, }, runs: runRows.map(runRowToSummary), messages: messageRows.map((row) => ({ id: String(row.id), role: String(row.role), content: String(row.content), createdAt: new Date(toInt(row.created_at)).toISOString(), })), }; } } //# sourceMappingURL=lucid-governance-queries.js.map