@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
180 lines • 7.79 kB
JavaScript
/**
* The `@adonis-agora/diagnostics` emit capability, published on this global slot at that package's
* module load. `@adonis-agora/agent` reads it STRUCTURALLY — it never imports or depends on the
* diagnostics package. When diagnostics isn't installed the slot is empty and emitting is an inert
* no-op that never throws back into the agent loop.
*
* Events land on `agora:agent:<event>`; the payload shapes below are the contract every consumer
* (dashboard / telescope) reads.
*/
import diagnostics_channel from 'node:diagnostics_channel';
const EMIT_SLOT = Symbol.for('@agora/diagnostics:emit');
/**
* Publish an agent event on `agora:agent:<event>` via the structural diagnostics slot. No-op when
* diagnostics isn't installed (the slot is empty) — and it never throws back into the agent loop.
*/
export function publishAgent(event, payload) {
const emit = globalThis[EMIT_SLOT];
if (typeof emit === 'function') {
try {
emit('agent', event, payload);
}
catch {
// diagnostics must never break an agent run
}
}
}
export function publishAgentRunStarted(payload) {
publishAgent('run.started', payload);
}
export function publishAgentMessage(payload) {
publishAgent('message', payload);
}
export function publishAgentToolCall(payload) {
publishAgent('tool-call', payload);
}
export function publishAgentQuotaExceeded(payload) {
publishAgent('quota.exceeded', payload);
}
export function publishAgentRunFailed(payload) {
publishAgent('run.failed', payload);
}
export function publishAgentRunCancelled(payload) {
publishAgent('run.cancelled', payload);
}
export function publishAgentRunFinished(payload) {
publishAgent('run.finished', payload);
}
export function publishAgentDelegated(payload) {
publishAgent('delegated', payload);
}
export function publishAgentRetrieved(payload) {
publishAgent('retrieved', payload);
}
export function publishAgentToolRetry(payload) {
publishAgent('tool.retry', payload);
}
// ── Run-tracing spans ─────────────────────────────────────────────────────────
//
// Point events above tell you WHAT happened; spans tell you the SHAPE of a turn — a start/end pair
// per genuinely-executed operation, all correlated by `traceId = runId`, so telescope renders one
// turn as a waterfall. Spans ride the SAME `agora:agent:*` convention as the point events, but on the
// five span sub-channels `agora:agent:<event>:<phase>` (start / end / asyncStart / asyncEnd / error) —
// byte-for-byte the wire shape `@adonis-agora/diagnostics`' own `trace()` publishes, so a span-aware
// bridge reads agent spans with ZERO agent→diagnostics coupling.
//
// We publish them directly over Node's builtin `node:diagnostics_channel` (a Node builtin — no
// diagnostics import, no emit slot needed), exactly as the media-ingestion subscribe path does. Cost
// is a handful of `hasSubscribers` reads when nothing is listening.
//
// REPLAY SAFETY: `spannedAgent` MUST be called from INSIDE a `hooks.step` body (→ `ctx.localStep`).
// Durable replay skips step bodies and returns the checkpoint, so a replayed run never re-emits a
// span. The root `turn` span (which spans the whole loop) is therefore a RUNNER concern: the inline
// runner emits it once around the loop; the durable runner does not emit a body-level root (it would
// re-emit per replay slice) and relies on the `traceId = runId` grouping to root the trace.
/** Span envelope schema version — matches `@adonis-agora/diagnostics`' `SPAN_SCHEMA_VERSION`. */
const SPAN_SCHEMA_VERSION = 1;
const SPAN_CHANNEL_PREFIX = 'agora';
const SPAN_LIB = 'agent';
/** Per-event memo of the resolved span channels — the steady state is a single `Map.get`. */
const spanChannelCache = new Map();
function getSpanChannels(event) {
const cached = spanChannelCache.get(event);
if (cached !== undefined) {
return cached;
}
const base = `${SPAN_CHANNEL_PREFIX}:${SPAN_LIB}:${event}`;
const channels = {
start: diagnostics_channel.channel(`${base}:start`),
end: diagnostics_channel.channel(`${base}:end`),
asyncStart: diagnostics_channel.channel(`${base}:asyncStart`),
asyncEnd: diagnostics_channel.channel(`${base}:asyncEnd`),
error: diagnostics_channel.channel(`${base}:error`),
};
spanChannelCache.set(event, channels);
return channels;
}
/** True when ANY of the five span sub-channels currently has a subscriber. */
function anySpanSubscribed(channels) {
return (channels.start.hasSubscribers ||
channels.end.hasSubscribers ||
channels.asyncStart.hasSubscribers ||
channels.asyncEnd.hasSubscribers ||
channels.error.hasSubscribers);
}
let spanCounter = 0;
/** A cheap, process-unique span id. Allocated only when a span is actually observed. */
function nextSpanId() {
spanCounter = (spanCounter + 1) >>> 0;
return `${Date.now().toString(36)}-${spanCounter.toString(36)}`;
}
/** Publish one phase on its sub-channel; never throws back into the agent loop. */
function publishSpanPhase(channel, phase, event, spanId, traceId, extra) {
if (!channel.hasSubscribers) {
return;
}
try {
channel.publish({
v: SPAN_SCHEMA_VERSION,
ts: Date.now(),
lib: SPAN_LIB,
event,
phase,
spanId,
traceId,
...extra,
});
}
catch {
// Observability must never break the traced code path.
}
}
/**
* Wrap a genuinely-executing async operation in an `agora:agent:<event>` span correlated to its run
* by `traceId = runId`, WITHOUT letting the operation's raw return value ride the span envelope: only
* the (redaction-safe) `summarize`d metadata is published as the span `result`, while the real value
* is handed straight back to the caller. Zero-cost when no span sub-channel has a subscriber (runs
* `run()` directly). Never throws from the span machinery — only `run()`'s own rejection propagates.
*
* Mirrors the async lifecycle of `@adonis-agora/diagnostics`' `trace()`: `start` → `end` (sync
* portion) → `asyncStart` → `asyncEnd` (with the summary) on success, or `error` on rejection.
*
* MUST be called from inside a `hooks.step` body (→ `ctx.localStep`) so durable replay — which skips
* step bodies — never re-emits the span. See the module header.
*/
export async function spannedAgent(event, traceId, payload, run, summarize) {
const channels = getSpanChannels(event);
if (!anySpanSubscribed(channels)) {
return run();
}
const spanId = nextSpanId();
const startedAt = performance.now();
publishSpanPhase(channels.start, 'start', event, spanId, traceId, { payload });
publishSpanPhase(channels.end, 'end', event, spanId, traceId, {
durationMs: performance.now() - startedAt,
});
publishSpanPhase(channels.asyncStart, 'asyncStart', event, spanId, traceId, {});
try {
const value = await run();
let result;
try {
result = summarize(value);
}
catch {
result = undefined;
}
publishSpanPhase(channels.asyncEnd, 'asyncEnd', event, spanId, traceId, {
...(result !== undefined ? { result } : {}),
durationMs: performance.now() - startedAt,
});
return value;
}
catch (error) {
publishSpanPhase(channels.error, 'error', event, spanId, traceId, {
error,
durationMs: performance.now() - startedAt,
});
throw error;
}
}
//# sourceMappingURL=diagnostics.js.map