@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
442 lines • 19.8 kB
JavaScript
import { percentile } from '../run-metrics.js';
/**
* The agent "Agent" dashboard data providers. Every provider here is ENTRY-BACKED: it aggregates the
* `agora:agent:*` lifecycle events that `@adonis-agora/agent` emits through `@adonis-agora/diagnostics`
* (via `src/diagnostics.ts`'s `publishAgent`) and that `@adonis-agora/telescope`'s generic diagnostics
* watcher records — one entry per publish, stored as `type: 'diagnostic'`, `tag: 'lib:agent'`, with the
* agent payload preserved verbatim under `content.payload`.
*
* No agent-specific watcher is contributed: capture is entirely handled by the generic bridge, so the
* extension only has to SURFACE the recorded history. This mirrors `@adonis-agora/media/telescope`'s
* entry-backed providers, and needs zero coupling to the agent container internals — no NestJS-style
* DI/read-model resolution.
*/
/**
* Newest-first cap on how many recorded agent entries a provider scans. Shared by every
* `lib:agent` consumer of `fetchEntries` — including `rag-data-providers.ts`, since retrieval
* events are recorded on this SAME generic slice (Adonis's diagnostics bridge has no per-event
* entry type to give RAG its own window; see that file's header for what this implies).
*/
export const ENTRY_LIMIT = 5_000;
/** Default rollup window: 24h. */
export const DAY_MS = 24 * 60 * 60 * 1000;
const contentOf = (e) => (e.content ?? {});
/** Epoch millis of an entry's `createdAt`, or `0` when absent. Exported for `rag-data-providers.ts`. */
export const atOf = (e) => (e.createdAt ? +new Date(e.createdAt) : 0);
const isoTime = (ms) => ms ? `${new Date(ms).toISOString().replace('T', ' ').slice(0, 19)}Z` : '';
/** Fetch captured `agora:agent:*` entries from Telescope storage (newest-first). */
export async function fetchEntries(ctx, limit = ENTRY_LIMIT) {
return ctx.store.list({ type: 'diagnostic', tag: 'lib:agent', limit });
}
/** A `tool-call` entry for a call that ran, not one re-published when the journal replayed it. */
function isFreshToolCall(e) {
const content = contentOf(e);
return content.event === 'tool-call' && content.payload?.replayed !== true;
}
function countEvent(entries, event) {
let n = 0;
for (const e of entries)
if (contentOf(e).event === event)
n += 1;
return n;
}
/** Split entries into current `(now-window, now]` and previous `(now-2window, now-window]`. */
function splitWindows(entries, windowMs, now) {
if (windowMs <= 0)
return { current: entries, previous: [] };
const start = now - windowMs;
const prevStart = start - windowMs;
return {
current: entries.filter((e) => atOf(e) > start && atOf(e) <= now),
previous: entries.filter((e) => atOf(e) > prevStart && atOf(e) <= start),
};
}
/**
* Build N equal-width time buckets spanning from the oldest entry to now, each starting empty.
* Exported so `rag-data-providers.ts` buckets retrieval entries the same way the agent providers
* bucket everything else, instead of a second, drifting bucketing scheme.
*/
export function timeBuckets(entries, count, emptyRow) {
const now = Date.now();
let minTime = now;
for (const e of entries) {
const at = atOf(e) || now;
if (at < minTime)
minTime = at;
}
const span = Math.max(now - minTime, 1);
const bucketSize = span / count;
const rows = Array.from({ length: count }, (_, i) => ({
label: new Date(minTime + i * bucketSize).toISOString().slice(11, 16),
...emptyRow(),
}));
return { rows, minTime, bucketSize };
}
/** Which of `timeBuckets`'s N buckets `e` falls into, clamped to `[0, count-1]`. */
export function bucketIndexFor(e, minTime, bucketSize, count) {
const at = atOf(e) || minTime;
return Math.min(count - 1, Math.max(0, Math.floor((at - minTime) / bucketSize)));
}
// ─── Runs ───────────────────────────────────────────────────────────────────
/**
* Net in-flight runs = `run.started` − every ending (`run.finished`, `run.failed`, `run.cancelled`)
* over the window (default 24h; `windowMs: 0` = all-time). Floored at 0. A cheap "how many agent runs
* are live right now" stat derived purely from the captured event stream — no live run store is
* consulted.
*/
export function agentActiveRunsProvider() {
return {
name: 'agent.activeRuns',
async resolve(query, ctx) {
const windowMs = Number(query?.windowMs ?? DAY_MS);
const { current } = splitWindows(await fetchEntries(ctx), windowMs, Date.now());
const started = countEvent(current, 'run.started');
const ended = countEvent(current, 'run.finished') +
countEvent(current, 'run.failed') +
countEvent(current, 'run.cancelled');
return { value: Math.max(0, started - ended) };
},
};
}
/**
* Token usage = total (input + output) tokens summed from `run.finished` over the window (default 24h),
* with `delta` vs the prior window and an 8-point spark. The entry-backed proxy for "spend/usage" — no
* pricing read-model is joined (that would need host DI, which this extension deliberately avoids).
*/
export function agentTokenUsageProvider() {
return {
name: 'agent.tokenUsage',
async resolve(query, ctx) {
const windowMs = Number(query?.windowMs ?? DAY_MS);
const now = Date.now();
const { current, previous } = splitWindows(await fetchEntries(ctx), windowMs, now);
const tokensOf = (list) => {
let total = 0;
for (const e of list) {
if (contentOf(e).event !== 'run.finished')
continue;
const p = contentOf(e).payload ?? {};
total += (p.inputTokens ?? 0) + (p.outputTokens ?? 0);
}
return total;
};
const value = tokensOf(current);
const delta = previous.length > 0 ? value - tokensOf(previous) : undefined;
const sparkBuckets = 8;
const bucketMs = (windowMs > 0 ? windowMs : Math.max(now, 1)) / sparkBuckets;
const start = now - (windowMs > 0 ? windowMs : now);
const spark = Array.from({ length: sparkBuckets }, (_, i) => {
const from = start + i * bucketMs;
return tokensOf(current.filter((e) => atOf(e) > from && atOf(e) <= from + bucketMs));
});
return delta === undefined ? { value, spark } : { value, delta, spark };
},
};
}
/**
* Tool-call success rate = executed / (executed + rejected + failed) over the window; 1 when no data.
* `rejected` counts an approval that was denied — a first-class governance signal, not a crash.
*/
export function agentToolCallSuccessRateProvider() {
return {
name: 'agent.toolCallSuccessRate',
async resolve(query, ctx) {
const windowMs = Number(query?.windowMs ?? DAY_MS);
const { current } = splitWindows(await fetchEntries(ctx), windowMs, Date.now());
let executed = 0;
let other = 0;
for (const e of current) {
if (!isFreshToolCall(e))
continue;
const status = contentOf(e).payload?.status;
if (status === 'executed')
executed += 1;
else if (status === 'rejected' || status === 'failed')
other += 1;
}
const total = executed + other;
return { value: total === 0 ? 1 : executed / total, min: 0, max: 1 };
},
};
}
/** Runs over time — `run.started` (started) vs `run.finished` (finished) per bucket. */
export function agentRunsOverTimeProvider() {
return {
name: 'agent.runsOverTime',
async resolve(query, ctx) {
const entries = await fetchEntries(ctx);
const buckets = Math.max(1, Number(query?.buckets ?? 24));
const { rows, minTime, bucketSize } = timeBuckets(entries, buckets, () => ({
started: 0,
finished: 0,
}));
for (const e of entries) {
const event = contentOf(e).event;
if (event !== 'run.started' && event !== 'run.finished')
continue;
const row = rows[bucketIndexFor(e, minTime, bucketSize, buckets)];
if (!row)
continue;
if (event === 'run.started')
row.started += 1;
else
row.finished += 1;
}
return { rows };
},
};
}
/** Token throughput over time — input/output tokens per bucket, summed from `run.finished`. */
export function agentTokensOverTimeProvider() {
return {
name: 'agent.tokensOverTime',
async resolve(query, ctx) {
const entries = await fetchEntries(ctx);
const buckets = Math.max(1, Number(query?.buckets ?? 24));
const { rows, minTime, bucketSize } = timeBuckets(entries, buckets, () => ({
input: 0,
output: 0,
}));
for (const e of entries) {
if (contentOf(e).event !== 'run.finished')
continue;
const row = rows[bucketIndexFor(e, minTime, bucketSize, buckets)];
if (!row)
continue;
const p = contentOf(e).payload ?? {};
row.input += p.inputTokens ?? 0;
row.output += p.outputTokens ?? 0;
}
return { rows };
},
};
}
/** Recent finished runs (newest first) as table rows: time, run, thread, steps, tokens. */
export function agentRecentRunsProvider() {
return {
name: 'agent.recentRuns',
async resolve(query, ctx) {
const limit = Math.min(200, Math.max(10, Number(query?.limit ?? 50)));
const rows = (await fetchEntries(ctx))
.filter((e) => contentOf(e).event === 'run.finished')
.sort((a, b) => atOf(b) - atOf(a))
.slice(0, limit)
.map((e) => {
const p = contentOf(e).payload ?? {};
return {
time: isoTime(atOf(e)),
runId: p.runId ?? '',
thread: p.threadId ?? '',
steps: p.steps ?? 0,
tokens: (p.inputTokens ?? 0) + (p.outputTokens ?? 0),
};
});
return { rows };
},
};
}
// ─── Run latency & failures ──────────────────────────────────────────────────
/** Equal-width buckets a distribution panel draws. */
const DISTRIBUTION_BUCKETS = 8;
const durationLabel = (ms) => ms < 1000 ? `${Math.round(ms)}ms` : `${Math.round(ms / 100) / 10}s`;
/**
* Turn latency as a distribution, from `run.finished` over the window (default 24h; `windowMs: 0` =
* all-time): `query.metric` `'ttft'` (default — turn start → first text frame; a turn that streamed
* no text has no sample) or `'duration'` (the whole turn). A delegation's child run is left out: it is
* part of its parent's turn, the same rule the read-model's `RunReliability.latency` follows.
* Percentiles are nearest-rank and omitted when there are no samples.
*/
export function agentLatencyDistributionProvider() {
return {
name: 'agent.latency',
async resolve(query, ctx) {
const windowMs = Number(query?.windowMs ?? DAY_MS);
const metric = query?.metric === 'duration' ? 'durationMs' : 'ttftMs';
const { current } = splitWindows(await fetchEntries(ctx), windowMs, Date.now());
const samples = [];
for (const e of current) {
const content = contentOf(e);
if (content.event !== 'run.finished' || content.payload?.parentRunId !== undefined)
continue;
const value = content.payload?.[metric];
if (typeof value === 'number' && Number.isFinite(value))
samples.push(value);
}
samples.sort((a, b) => a - b);
const max = samples.at(-1) ?? 0;
const size = Math.max(1, Math.ceil((max + 1) / DISTRIBUTION_BUCKETS));
const buckets = Array.from({ length: DISTRIBUTION_BUCKETS }, (_, i) => ({
label: durationLabel(i * size),
count: 0,
}));
for (const value of samples) {
const bucket = buckets[Math.min(DISTRIBUTION_BUCKETS - 1, Math.floor(value / size))];
if (bucket)
bucket.count += 1;
}
if (samples.length === 0)
return { buckets };
return {
buckets,
p50: percentile(samples, 0.5),
p95: percentile(samples, 0.95),
p99: percentile(samples, 0.99),
};
},
};
}
/**
* Failed runs by the stable code their stream `error` frame carried, from `run.failed` over the
* window (default 24h; `windowMs: 0` = all-time), most frequent first. An entry without a code is
* `unknown`.
*/
export function agentFailuresByCodeProvider() {
return {
name: 'agent.failuresByCode',
async resolve(query, ctx) {
const windowMs = Number(query?.windowMs ?? DAY_MS);
const { current } = splitWindows(await fetchEntries(ctx), windowMs, Date.now());
const counts = new Map();
for (const e of current) {
const content = contentOf(e);
if (content.event !== 'run.failed')
continue;
const code = content.payload?.code ?? 'unknown';
counts.set(code, (counts.get(code) ?? 0) + 1);
}
return {
segments: [...counts.entries()]
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label)),
};
},
};
}
// ─── Tools & approvals ────────────────────────────────────────────────────────
/** Tool calls over time — executed/rejected/failed per bucket (the `tool-call` event). */
export function agentToolCallsOverTimeProvider() {
return {
name: 'agent.toolCallsOverTime',
async resolve(query, ctx) {
const entries = await fetchEntries(ctx);
const buckets = Math.max(1, Number(query?.buckets ?? 24));
const { rows, minTime, bucketSize } = timeBuckets(entries, buckets, () => ({
executed: 0,
rejected: 0,
failed: 0,
}));
for (const e of entries) {
if (!isFreshToolCall(e))
continue;
const row = rows[bucketIndexFor(e, minTime, bucketSize, buckets)];
if (!row)
continue;
const status = contentOf(e).payload?.status;
if (status === 'executed')
row.executed += 1;
else if (status === 'rejected')
row.rejected += 1;
else if (status === 'failed')
row.failed += 1;
}
return { rows };
},
};
}
/** Recent tool calls (newest first) as table rows: time, run, tool, type, status. */
export function agentRecentToolCallsProvider() {
return {
name: 'agent.recentToolCalls',
async resolve(query, ctx) {
const limit = Math.min(200, Math.max(10, Number(query?.limit ?? 50)));
const rows = (await fetchEntries(ctx))
.filter((e) => contentOf(e).event === 'tool-call')
.sort((a, b) => atOf(b) - atOf(a))
.slice(0, limit)
.map((e) => {
const p = contentOf(e).payload ?? {};
return {
time: isoTime(atOf(e)),
runId: p.runId ?? '',
tool: p.toolName ?? '',
type: p.toolType ?? '',
status: p.status ?? '',
};
});
return { rows };
},
};
}
/**
* Recent approval decisions (newest first): the `action`-type tool calls — the ones that pass through
* the human-approval gate — surfaced with their outcome (`executed` = approved/auto, `rejected` =
* denied). Read-type tool calls never require approval and are excluded.
*/
export function agentRecentApprovalsProvider() {
return {
name: 'agent.recentApprovals',
async resolve(query, ctx) {
const limit = Math.min(200, Math.max(10, Number(query?.limit ?? 50)));
const rows = (await fetchEntries(ctx))
.filter((e) => contentOf(e).event === 'tool-call' && contentOf(e).payload?.toolType === 'action')
.sort((a, b) => atOf(b) - atOf(a))
.slice(0, limit)
.map((e) => {
const p = contentOf(e).payload ?? {};
return {
time: isoTime(atOf(e)),
runId: p.runId ?? '',
tool: p.toolName ?? '',
status: p.status ?? '',
};
});
return { rows };
},
};
}
// ─── Delegations ────────────────────────────────────────────────────────────
/** Sub-agent delegations over time — the `delegated` event per bucket. */
export function agentDelegationsOverTimeProvider() {
return {
name: 'agent.delegationsOverTime',
async resolve(query, ctx) {
const entries = await fetchEntries(ctx);
const buckets = Math.max(1, Number(query?.buckets ?? 24));
const { rows, minTime, bucketSize } = timeBuckets(entries, buckets, () => ({
delegations: 0,
}));
for (const e of entries) {
if (contentOf(e).event !== 'delegated')
continue;
const row = rows[bucketIndexFor(e, minTime, bucketSize, buckets)];
if (row)
row.delegations += 1;
}
return { rows };
},
};
}
/** Recent delegations (newest first) as table rows: time, run, from, to. */
export function agentRecentDelegationsProvider() {
return {
name: 'agent.recentDelegations',
async resolve(query, ctx) {
const limit = Math.min(200, Math.max(10, Number(query?.limit ?? 50)));
const rows = (await fetchEntries(ctx))
.filter((e) => contentOf(e).event === 'delegated')
.sort((a, b) => atOf(b) - atOf(a))
.slice(0, limit)
.map((e) => {
const p = contentOf(e).payload ?? {};
return {
time: isoTime(atOf(e)),
runId: p.runId ?? '',
from: p.fromAgent ?? '',
to: p.toAgent ?? '',
};
});
return { rows };
},
};
}
//# sourceMappingURL=data-providers.js.map