UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

477 lines • 20.5 kB
import { genuiRowsInRange, summarizeGenuiCalls } from '../genui-telemetry.js'; import { summarizeLatency, summarizeRunMetrics } from '../run-metrics.js'; import { modelPriceCandidates } from '../spi/pricing-store.js'; /** 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); } /** Whether a run's/tool-call's day falls within an optional (both-sides-optional) day range. */ function withinOptionalRange(day, range) { if (range?.from !== undefined && day < range.from) return false; if (range?.to !== undefined && day > range.to) return false; return true; } /** Map an in-memory run row to the read-model {@link RunSummaryRow}. */ function runToSummary(run) { const finishedAtMs = run.finishedAt !== undefined ? Date.parse(run.finishedAt) : null; const startedAtMs = Date.parse(run.startedAt); return { runId: run.runId, threadId: run.threadId, actorRef: run.actorRef, tenantRef: run.tenantRef ?? null, agentName: run.agentName ?? null, parentRunId: run.parentRunId ?? null, status: run.status, startedAt: run.startedAt, finishedAt: run.finishedAt ?? null, durationMs: finishedAtMs === null ? null : finishedAtMs - startedAtMs, stepCount: run.stepCount, inputTokens: run.inputTokens, outputTokens: run.outputTokens, costUsd: run.costUsd ?? null, error: run.error ?? null, durable: run.durable, ttftMs: run.ttftMs ?? null, errorCode: run.errorCode ?? null, modelId: run.modelId ?? null, }; } /** Map an in-memory tool-call feed row to the read-model {@link RunToolCallRow}. */ function toolCallToDetail(row) { return { toolCallId: row.toolCallId, toolName: row.toolName, toolType: row.toolType, status: row.status, input: row.input, output: row.output ?? null, error: row.error ?? null, executionMs: row.executionMs ?? null, createdAt: row.createdAt, }; } /** * Token-ledger estimate for one usage row against the supplied pricing map: the uncached input at * the input rate, cache-write/cache-read tokens at their own rates (falling back to the input rate * when unpriced), plus output at the output rate. An unpriced model (missing from the map) * contributes 0 — so the default empty map yields 0 cost everywhere while still counting tokens. * Cache token counts are subsets of `inputTokens`, so the uncached remainder is the difference. */ function estimateFromTokens(pricing, row) { // Mesma resolução do adaptador Lucid — o dublê tem que errar e acertar nos MESMOS casos que a // produção, senão ele deixa de ser um dublê e vira uma segunda implementação. const price = modelPriceCandidates(row.modelId) .map((candidate) => pricing.get(candidate)) .find((found) => found !== undefined); if (price === undefined) { return 0; } const cacheWriteTokens = row.cacheWriteTokens ?? 0; const cacheReadTokens = row.cacheReadTokens ?? 0; const uncachedInputTokens = row.inputTokens - cacheWriteTokens - cacheReadTokens; return ((uncachedInputTokens / 1_000_000) * price.inputPricePer1m + (cacheWriteTokens / 1_000_000) * (price.cacheWritePricePer1m ?? price.inputPricePer1m) + (cacheReadTokens / 1_000_000) * (price.cacheReadPricePer1m ?? price.inputPricePer1m) + (row.outputTokens / 1_000_000) * price.outputPricePer1m); } /** * A fully in-memory {@link AgentGovernanceQueries} for unit tests and the offline demo. Aggregates * the usage/tool-call/thread rows recorded on an {@link InMemoryAgentStore}. Pricing is an optional * map keyed by `modelId`; omit it (default empty) for a zero-cost read-model that still reports * token usage. Mirrors the SQL adapters' cost formula and inclusive-day semantics. */ export class InMemoryGovernanceQueries { store; pricing; constructor(store, pricing = new Map()) { this.store = store; this.pricing = pricing; } inRange(day, range) { return day >= range.fromDay && day <= range.toDay; } /** Provider-reported cost wins per row; otherwise the cache-aware token estimate. */ rowCost(row) { return row.costUsd ?? estimateFromTokens(this.pricing, row); } async spendByModel(range) { const byModel = new Map(); for (const row of this.store.governanceUsage()) { if (!this.inRange(row.day, range)) { continue; } 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); byModel.set(row.modelId, bucket); } const result = []; for (const [modelId, bucket] of byModel) { result.push({ modelId, requests: bucket.requests, inputTokens: bucket.inputTokens, outputTokens: bucket.outputTokens, costUsd: bucket.costUsd, }); } result.sort((left, right) => right.costUsd - left.costUsd || left.modelId.localeCompare(right.modelId)); return result; } async spendByActor(range) { const byActor = new Map(); for (const row of this.store.governanceUsage()) { if (!this.inRange(row.day, range)) { continue; } 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); byActor.set(row.actorRef, bucket); } const result = []; for (const [actorRef, bucket] of byActor) { result.push({ actorRef, requests: bucket.requests, totalTokens: bucket.totalTokens, costUsd: bucket.costUsd, }); } result.sort((left, right) => right.costUsd - left.costUsd || left.actorRef.localeCompare(right.actorRef)); return result; } async usageTrend(range) { const byDay = new Map(); for (const row of this.store.governanceUsage()) { if (!this.inRange(row.day, range)) { continue; } const bucket = byDay.get(row.day) ?? { totalTokens: 0, costUsd: 0 }; bucket.totalTokens += row.inputTokens + row.outputTokens; bucket.costUsd += this.rowCost(row); 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; } async recentToolCalls(limit) { return [...this.store.governanceToolCalls()] .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) .slice(0, limit) .map((row) => ({ toolCallId: row.toolCallId, toolName: row.toolName, toolType: row.toolType, status: row.status, threadId: row.threadId, createdAt: row.createdAt, })); } async recentThreads(limit) { const usage = this.store.governanceUsage(); return [...this.store.governanceThreads()] .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) .slice(0, limit) .map((thread) => { const totalTokens = usage .filter((row) => row.threadId === thread.threadId) .reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0); return { threadId: thread.threadId, title: thread.title, actorRef: thread.actorRef, messageCount: thread.messageCount, totalTokens, lastActivityAt: thread.updatedAt, }; }); } // ── Run lifecycle read-model (behavioral twin of LucidGovernanceQueries) ──── async listRuns(filter = {}) { 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; })(); const matched = this.store .governanceRuns() .filter((run) => { if (filter.actor !== undefined && run.actorRef !== filter.actor) return false; if (filter.agent !== undefined && (run.agentName ?? '') !== filter.agent) return false; if (filter.status !== undefined && run.status !== filter.status) return false; const day = run.startedAt.slice(0, 10); if (filter.from !== undefined && day < filter.from) return false; if (filter.to !== undefined && day > filter.to) return false; return true; }) // Newest-first, `runId` desc for a stable tiebreak (mirrors the Lucid ordering). .sort((left, right) => right.startedAt.localeCompare(left.startedAt) || right.runId.localeCompare(left.runId)); const page = matched.slice(offset, offset + limit); const hasMore = matched.length > offset + limit; return { items: page.map(runToSummary), nextCursor: hasMore ? String(offset + limit) : null, // Forward-only, exactly like the Lucid twin — see `src/pagination.ts`. prevCursor: null, hasNext: hasMore, hasPrev: false, }; } async runDetail(runId) { const run = this.store.governanceRuns().find((candidate) => candidate.runId === runId); if (run === undefined) return null; const messages = this.store .governanceMessages() .filter((message) => message.runId === runId) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map((message) => ({ id: message.id, role: message.role, content: message.content, createdAt: message.createdAt, })); const toolCalls = this.store .governanceToolCalls() .filter((call) => call.runId === runId) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map(toolCallToDetail); const usage = this.store .governanceUsage() .filter((row) => row.runId === runId) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map((row) => ({ modelId: row.modelId, purpose: row.purpose ?? 'chat', inputTokens: row.inputTokens, outputTokens: row.outputTokens, costUsd: row.costUsd ?? null, createdAt: row.createdAt, })); return { run: runToSummary(run), messages, toolCalls, approvals: toolCalls.filter((call) => call.status === 'pending_approval'), usage, }; } async pendingApprovals(filter = {}) { const limit = clampLimit(filter.limit); const runByThreadActor = new Map(this.store.governanceRuns().map((run) => [run.runId, run.actorRef])); const threadActor = new Map(this.store.governanceThreads().map((thread) => [thread.threadId, thread.actorRef])); const result = []; const pending = this.store .governanceToolCalls() .filter((call) => call.status === 'pending_approval') .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); for (const call of pending) { const actorRef = (call.runId !== undefined ? runByThreadActor.get(call.runId) : undefined) ?? threadActor.get(call.threadId) ?? ''; if (filter.actor !== undefined && actorRef !== filter.actor) continue; result.push({ toolCallId: call.toolCallId, toolName: call.toolName, input: call.input, threadId: call.threadId, runId: call.runId ?? null, actorRef, requestedAt: call.createdAt, }); if (result.length >= limit) break; } return result; } async genuiStats(range) { return summarizeGenuiCalls(genuiRowsInRange(this.store.genuiCallRows(), range)); } async perToolStats(range = {}) { const byTool = new Map(); for (const call of this.store.governanceToolCalls()) { if (!withinOptionalRange(call.createdAt.slice(0, 10), range)) continue; const key = `${call.toolName} ${call.toolType}`; const bucket = byTool.get(key) ?? { toolName: call.toolName, toolType: call.toolType, calls: 0, failed: 0, rejected: 0, durations: [], }; bucket.calls += 1; if (call.status === 'failed') bucket.failed += 1; if (call.status === 'rejected') bucket.rejected += 1; if (call.executionMs !== undefined) bucket.durations.push(call.executionMs); 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; } async runReliability(range = {}) { let completed = 0; let failed = 0; let cancelled = 0; let running = 0; let durationSum = 0; let durationCount = 0; let total = 0; const inRange = []; const byAgent = new Map(); const byDay = new Map(); for (const run of this.store.governanceRuns()) { if (!withinOptionalRange(run.startedAt.slice(0, 10), range)) continue; total += 1; inRange.push(run); if (run.status === 'completed') completed += 1; else if (run.status === 'failed') failed += 1; else if (run.status === 'cancelled') cancelled += 1; else running += 1; if (run.status !== 'running' && run.finishedAt !== undefined) { durationSum += Date.parse(run.finishedAt) - Date.parse(run.startedAt); durationCount += 1; } const agentName = run.agentName ?? null; const agentBucket = byAgent.get(agentName) ?? { runs: 0, failed: 0, completed: 0 }; agentBucket.runs += 1; if (run.status === 'failed') agentBucket.failed += 1; if (run.status === 'completed') agentBucket.completed += 1; byAgent.set(agentName, agentBucket); const day = run.startedAt.slice(0, 10); const dayBucket = byDay.get(day) ?? { runs: 0, failed: 0 }; dayBucket.runs += 1; if (run.status === 'failed') dayBucket.failed += 1; byDay.set(day, dayBucket); } 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(inRange.map((run) => ({ status: run.status, parentRunId: run.parentRunId ?? null, startedAt: Date.parse(run.startedAt), finishedAt: run.finishedAt !== undefined ? Date.parse(run.finishedAt) : null, ttftMs: run.ttftMs ?? null, errorCode: run.errorCode ?? null, }))), }; } /** * The thread governance drill-down (behavioral twin of `LucidGovernanceQueries.threadDetail`): * metadata + a LIFETIME usage rollup + the most recent runs/messages. `null` when unknown. The * in-memory store never returns a soft-deleted thread from `governanceThreads()` at all, so * `deleted` is always `false` here — mirroring the store's own delete semantics. */ async threadDetail(threadId) { const thread = this.store.governanceThreads().find((t) => t.threadId === threadId); if (thread === undefined) return null; const usage = this.store.governanceUsage().filter((row) => row.threadId === threadId); 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), 0); const runs = this.store .governanceRuns() .filter((run) => run.threadId === threadId) .sort((left, right) => right.startedAt.localeCompare(left.startedAt)); const messages = this.store .governanceMessages() .filter((message) => message.threadId === threadId) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); return { threadId: thread.threadId, title: thread.title, actorRef: thread.actorRef, // GovernanceThreadRow carries no separate createdAt — updatedAt is the closest fact the // in-memory store tracks, unlike the Lucid twin which reads the real thread row. createdAt: thread.updatedAt, updatedAt: thread.updatedAt, deleted: false, usage: { totalTokens, costUsd, runCount: runs.length, messageCount: messages.length, }, runs: runs.slice(0, 20).map(runToSummary), messages: messages.slice(0, 50).map((message) => ({ id: message.id, role: message.role, content: message.content, createdAt: message.createdAt, })), }; } } //# sourceMappingURL=in-memory-governance-queries.js.map