UNPKG

@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
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