@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
265 lines • 11.9 kB
JavaScript
import { createHash } from 'node:crypto';
import { PACKAGE_ORIGIN } from '../package-origin.js';
import { ChannelDeliveryError } from './http.js';
/** Ids a store / engine takes whatever the provider's message id looks like. */
export function jobId(...parts) {
const id = `agora.channel:${parts.join(':')}`;
return id.length <= 160 ? id : `agora.channel:${createHash('sha256').update(id).digest('hex')}`;
}
/** The order key: one channel conversation. */
const lockKey = (job) => `${job.channel}:${job.conversation}`;
/** The checkpoint name of a phase run in-process — `gather` takes one per look at the burst. */
const localStepName = (phase, input) => phase === 'gather' ? `gather:${input.round ?? 0}` : phase;
/** A timer, for a wait the durable engine cannot hold. */
const timerUntil = (at) => new Promise((resolve) => {
const timer = setTimeout(resolve, Math.max(0, at - Date.now()));
timer.unref?.();
});
/** Each phase called right here, a wait a timer: what the inline executor runs a job with. */
export function inlineIo(runner) {
return {
phase: (phase, input) => runner.phase(phase, input),
sleepUntil: timerUntil,
};
}
/** In this process: a promise chain per conversation. Nothing survives a restart. */
export function inlineExecutor(run) {
const chains = new Map();
const inFlight = new Set();
const track = (work) => {
inFlight.add(work);
void work.finally(() => inFlight.delete(work));
return work;
};
return {
durable: false,
async enqueue(job) {
const key = lockKey(job);
const previous = chains.get(key) ?? Promise.resolve();
const next = track(previous.then(() => run(job)).catch(() => { }));
chains.set(key, next);
void next.finally(() => {
if (chains.get(key) === next)
chains.delete(key);
});
},
async later(job, _id, at) {
const timer = setTimeout(() => track(run(job).catch(() => { })), Math.max(0, at - Date.now()));
timer.unref?.();
},
async drain() {
while (inFlight.size > 0)
await Promise.allSettled([...inFlight]);
},
};
}
/** The workflow a message (or a resume) runs as: one at a time per channel conversation. */
export const CHANNEL_JOB_WORKFLOW = 'agora.channel.job';
/** A question's timeout: runs on its own, so it never waits behind the conversation. */
export const CHANNEL_TIMER_WORKFLOW = 'agora.channel.timer';
/** The dispatched step a phase runs as: `agora.channel.prepare`, `agora.channel.read`, … */
export const channelPhaseStep = (phase) => `agora.channel.${phase}`;
const PHASES = ['gather', 'prepare', 'act', 'read', 'timeout'];
/** `@adonis-agora/durable`'s stamp on its control-flow signals (a global symbol). */
const CONTROL_FLOW = Symbol.for('@agora/durable:control-flow');
/** A control-flow signal a workflow body surfaces (a suspend for a sleep, say) — not a failure. */
export const isControlFlowSignal = (error) => {
if (error === null || typeof error !== 'object')
return false;
const name = error.name;
return (name === 'WorkflowSuspended' ||
name === 'ContinueAsNew' ||
name === 'WorkflowBlocked' ||
CONTROL_FLOW in error);
};
const registeredOn = new WeakSet();
const servedOn = new WeakSet();
/** The step server each engine's channel phases are dispatched to, once one is known. */
const stepServers = new WeakMap();
/** How often a dispatched phase tells the engine it is still running. */
const HEARTBEAT_MS = 10_000;
/** How long a phase waits for its channel to be registered in the process it landed on. */
const REGISTRATION_WAIT_MS = 60_000;
const notRegistered = (channel) => new Error(`[@adonis-agora/agent] channel "${channel}" is not registered in this process — create its handler (channels.handle) at boot in every process that runs durable work`);
/** The channel's runner, waiting a minute for a handler created lazily (on its first webhook). */
async function runnerIn(runnerFor, channel) {
const deadline = Date.now() + REGISTRATION_WAIT_MS;
for (;;) {
const runner = runnerFor(channel);
if (runner !== undefined)
return runner;
if (Date.now() >= deadline)
throw notRegistered(channel);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
/**
* Serve every channel phase on `server`, once: the handler finds the channel named in the input in
* whatever process the step lands on, and runs that phase from its input alone.
*/
function servePhases(server, runnerFor) {
if (servedOn.has(server))
return;
servedOn.add(server);
for (const phase of PHASES) {
server.handle(channelPhaseStep(phase), async (input, log) => {
const { channel, ...rest } = input;
const runner = await runnerIn(runnerFor, channel);
const beat = setInterval(() => log?.heartbeat?.(), HEARTBEAT_MS);
beat.unref?.();
try {
return (await runner.phase(phase, rest)) ?? null;
}
finally {
clearInterval(beat);
}
});
}
}
/** The durable wait: `sleepUntil`, else `sleep`, else (an engine without either) a timer. */
const durableSleep = (ctx) => (at) => {
if (typeof ctx.sleepUntil === 'function')
return ctx.sleepUntil(at);
if (typeof ctx.sleep === 'function')
return ctx.sleep(Math.max(1, at - Date.now()));
return timerUntil(at);
};
/**
* Register the channel workflows on `engine`, once. Their body finds the job's channel with
* `runnerFor` — the handler `channels.handle()` registered under that name IN THIS PROCESS. A
* process that resumes channel jobs must have created the same handlers.
*
* - version `1`: every phase a `ctx.localStep`, in the process running the workflow — kept so runs
* started before an upgrade finish on the path they were recorded on;
* - version `2` (new runs): each phase a DISPATCHED step (`agora.channel.<phase>`) when `server` (or
* `engine.handleStep`) serves them — any process that registered the channel runs it, any
* process continues the workflow — else a `localStep`; and a burst (`coalesce`) waited for with
* the engine's durable sleep. Which of the two a run uses is recorded in its first step.
*/
export function registerChannelWorkflows(engine, runnerFor, server) {
const given = server ??
(typeof engine.handleStep === 'function'
? { handle: engine.handleStep.bind(engine) }
: undefined);
if (given !== undefined && !stepServers.has(engine)) {
stepServers.set(engine, given);
servePhases(given, runnerFor);
}
if (registeredOn.has(engine))
return;
registeredOn.add(engine);
/** The channel is registered here — given a minute for a handler created lazily. */
const channelStep = (ctx, job, value) => ctx.localStep('channel', async () => {
if (runnerFor(job.channel) === undefined)
throw notRegistered(job.channel);
return value();
}, { retries: 30, backoff: 'fixed', backoffMs: 2000 });
const localIo = (ctx, runner) => ({
phase: (phase, input) => ctx.localStep(localStepName(phase, input), () => runner.phase(phase, input)),
sleepUntil: durableSleep(ctx),
});
const dispatchedIo = (ctx, runner, channel) => ({
phase: (phase, input) => {
const step = ctx.step;
if (typeof step !== 'function')
throw new Error('[@adonis-agora/agent] ctx.step is gone');
// A liveness window per phase: a step whose job was lost (a worker gone, a broker that
// dropped it) is dispatched once more after it, then fails the job — never `pending` forever.
return step.call(ctx, channelPhaseStep(phase), { channel, ...input }, { timeoutMs: runner.stepTimeoutMs(phase), retries: 2 });
},
sleepUntil: durableSleep(ctx),
});
const v1 = async (ctx, input) => {
const job = input;
await channelStep(ctx, job, () => true);
const runner = runnerFor(job.channel);
if (runner === undefined)
return null;
// As version 1 always ran: no burst to wait for (its jobs never recorded one), phases local.
await runner.run(job.kind === 'message' ? { ...job, coalesce: false } : job, localIo(ctx, runner));
return null;
};
const v2 = async (ctx, input) => {
const job = input;
const mode = await channelStep(ctx, job, () => ({
dispatched: stepServers.has(engine) &&
typeof ctx.step === 'function' &&
runnerFor(job.channel)?.dispatched !== false,
}));
const runner = runnerFor(job.channel);
if (runner === undefined)
return null;
await runner.run(job, mode.dispatched === true ? dispatchedIo(ctx, runner, job.channel) : localIo(ctx, runner));
return null;
};
const origin = { origin: PACKAGE_ORIGIN };
const singleton = {
...origin,
singleton: { key: (input) => lockKey(input), limit: 1 },
};
engine.register(CHANNEL_JOB_WORKFLOW, '1', v1, singleton);
engine.register(CHANNEL_JOB_WORKFLOW, '2', v2, singleton);
engine.register(CHANNEL_TIMER_WORKFLOW, '1', v1, origin);
engine.register(CHANNEL_TIMER_WORKFLOW, '2', v2, origin);
}
/** As durable runs on `engine` — see {@link registerChannelWorkflows}. */
export function durableExecutor(engine) {
const started = new Set();
const start = async (workflow, job, id, at) => {
try {
await engine.start(workflow, job, id, at === undefined ? undefined : { startAt: at });
}
catch (error) {
// A run a driving dispatcher suspended (or continued as new) while starting it: taken.
const name = error?.name;
if (name !== 'WorkflowSuspended' && name !== 'ContinueAsNew')
throw error;
}
};
return {
durable: true,
async enqueue(job, id) {
await start(CHANNEL_JOB_WORKFLOW, job, id);
started.add(id);
},
async later(job, id, at) {
await start(CHANNEL_TIMER_WORKFLOW, job, id, at);
},
async drain() {
while (started.size > 0) {
const ids = [...started];
started.clear();
await Promise.allSettled(ids.map((id) => engine.waitForRun?.(id, { terminal: true }) ?? Promise.resolve()));
}
},
};
}
/** {@link ChannelRetryOptions.retryable}'s default. */
export function retryableByDefault(error) {
if (error instanceof ChannelDeliveryError)
return !error.definite;
const status = error?.status;
if (typeof status === 'number' && status >= 400 && status < 500)
return false;
const name = error?.name;
return name !== 'QuotaBlockedError' && name !== 'AttachmentRefusedError';
}
/** Run `fn`, again after a growing delay while it fails with a retryable error. */
export async function withRetries(fn, options = {}) {
const attempts = Math.max(1, options.attempts ?? 3);
const base = Math.max(0, options.backoffMs ?? 1000);
const max = Math.max(base, options.maxBackoffMs ?? 30_000);
const retryable = options.retryable ?? retryableByDefault;
for (let attempt = 1;; attempt++) {
try {
return await fn();
}
catch (error) {
if (attempt >= attempts || !retryable(error))
throw error;
const delay = Math.min(max, base * 2 ** (attempt - 1));
await new Promise((resolve) => setTimeout(resolve, delay * (0.5 + Math.random() / 2)));
}
}
}
//# sourceMappingURL=executor.js.map