@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
201 lines • 10.3 kB
TypeScript
import type { Actor } from '../types.js';
import type { InboundMessage } from './types.js';
/**
* How a channel's inbound messages are processed: one {@link ChannelJob} per message, in order per
* conversation, each made of named phases.
*
* - {@link inlineExecutor}: in this process, after the `200`. A restart loses what was in flight.
* - {@link durableExecutor}: as `@adonis-agora/durable` runs — persisted before the `200`, one at a
* time per conversation (a singleton key), each phase a checkpointed step (a dispatched one, when
* the engine has somewhere to serve it), and resumed by the engine's recovery after a crash.
*/
/** What a channel processes: a message, the rest of a run after its question, a question timing out. */
export type ChannelJob = {
kind: 'message';
channel: string;
conversation: string;
message: InboundMessage;
/** When the webhook took it (epoch ms) — what a turn's queue wait is measured from. */
receivedAt?: number;
/**
* Recorded in the conversation's burst log when it was taken (`coalesce` on): the job first
* looks for a burst to join, or to lead.
*/
coalesce?: boolean;
} | {
/** Read a run's stream (again) and deliver what was not delivered yet. */
kind: 'resume';
channel: string;
conversation: string;
runId: string;
threadId: string | null;
actor: Actor;
} | {
/** A question nobody answered in time: skip it, then resume the run. Runs outside the lock. */
kind: 'timeout';
channel: string;
conversation: string;
/** The run parked on the question (a delegate's, sometimes). */
runId: string;
toolCallId: string;
/** The run whose stream the person reads. */
streamRunId: string;
threadId: string | null;
actor: Actor;
};
/**
* The phases a job is made of. Each one is rebuilt from its (JSON) input — the job and what the
* phases before it returned — so it can run in any process that registered the channel.
*/
export type ChannelPhase = 'gather' | 'prepare' | 'act' | 'read' | 'timeout';
/** What one phase is handed: the job, and what the phases before it came to (all JSON). */
export interface ChannelPhaseInput {
job: ChannelJob;
/** `gather`: which look at the burst this is (0, 1, …). */
round?: number;
/** The messages the turn is made of, when it groups several (a burst). */
batch?: InboundMessage[];
/** What `prepare` came to. */
prepared?: unknown;
/** What `act` came to. */
turn?: unknown;
}
/** Runs one phase: checkpointed (durable — in this process, or dispatched) or just called (inline). */
export type ChannelPhaseRunner = <T>(phase: ChannelPhase, input: ChannelPhaseInput) => Promise<T>;
/** How a job runs its phases, and how it waits for a burst to end. */
export interface ChannelJobIo {
phase: ChannelPhaseRunner;
/** Wait until `at` (epoch ms): the durable engine's sleep, else a timer. */
sleepUntil(at: number): Promise<void>;
}
/** What a registered channel gives the executors: its job, and each of its phases on its own. */
export interface ChannelRunner {
run(job: ChannelJob, io: ChannelJobIo): Promise<void>;
phase(phase: ChannelPhase, input: ChannelPhaseInput): Promise<unknown>;
/** How long a dispatched phase may go without an answer before the engine gives up on it. */
stepTimeoutMs(phase: ChannelPhase): number;
/** `false` → this channel's phases always run as `localStep`s (`durableSteps: false`). */
dispatched?: boolean;
}
export interface ChannelExecutor {
readonly durable: boolean;
/**
* Take a job: persisted (durable) or queued (inline) when this resolves — what the webhook waits
* for before its `200`. Jobs of one conversation run one at a time, in the order they were taken.
*/
enqueue(job: ChannelJob, id: string): Promise<void>;
/** Run a job at `at` (epoch ms), outside the conversation's order. */
later(job: ChannelJob, id: string, at: number): Promise<void>;
/** Resolves once every job taken by this executor so far has finished. */
drain(): Promise<void>;
}
/** Ids a store / engine takes whatever the provider's message id looks like. */
export declare function jobId(...parts: string[]): string;
/** Each phase called right here, a wait a timer: what the inline executor runs a job with. */
export declare function inlineIo(runner: Pick<ChannelRunner, 'phase'>): ChannelJobIo;
/** In this process: a promise chain per conversation. Nothing survives a restart. */
export declare function inlineExecutor(run: (job: ChannelJob) => Promise<void>): ChannelExecutor;
/** What a dispatched step handler is given besides its input — a durable `StepLogger`'s slice. */
export interface ChannelStepLog {
/** Tell the engine the step is alive (rearms its timeout, where the transport carries beats). */
heartbeat?(progress?: unknown): void;
}
/**
* Where dispatched steps are served BY NAME — a durable `Transport` with `handle` (every built-in
* one has it: in-memory, BullMQ, database). A handler registered here runs in whatever process the
* transport hands the step to.
*/
export interface ChannelStepServer {
handle(name: string, fn: (input: unknown, log?: ChannelStepLog) => Promise<unknown>): void;
}
/**
* The slice of an `@adonis-agora/durable` `WorkflowEngine` the channels use — the engine itself
* satisfies it (`channels.handle(adapter, { durable: engine })`).
*/
export interface ChannelWorkflowEngine {
register(name: string, version: string, fn: (ctx: ChannelWorkflowCtx, input: unknown) => Promise<unknown>, opts?: {
singleton?: {
key: (input: unknown) => string;
limit?: number;
};
/** Package attribution stamped on every run (`WorkflowRun.origin`). */
origin?: string;
}): void;
start(workflow: string, input: unknown, runId: string, opts?: {
startAt?: number | Date;
}): Promise<unknown>;
waitForRun?(runId: string, opts?: {
timeoutMs?: number;
terminal?: boolean;
}): Promise<unknown>;
/**
* Serve a dispatched step by name — for an engine that serves its own steps. `@adonis-agora/durable`'s
* engine serves them on its transport instead: the channels find it in the app's container (or take
* it as `channels.handle(…, { durableSteps: transport })`). Neither → the phases run in the process
* that runs the workflow (`ctx.localStep`).
*/
handleStep?(name: string, fn: (input: unknown, log?: ChannelStepLog) => Promise<unknown>): void;
}
/** The slice of a durable `WorkflowCtx` a channel job uses. */
export interface ChannelWorkflowCtx {
localStep<T>(name: string, fn: () => Promise<T>, options?: {
retries?: number;
backoff?: 'fixed' | 'exp';
backoffMs?: number;
}): Promise<T>;
/**
* A dispatched step, routed to the handler served under `name` in any process. Absent → the
* phases run as `localStep`s.
*/
step?<T>(name: string, input: unknown, options?: {
retries?: number;
timeoutMs?: number;
pickupTimeoutMs?: number;
}): Promise<T>;
/** Suspend the run until `when` (durable). Absent → {@link sleep}, else a timer in this process. */
sleepUntil?(when: number | Date): Promise<void>;
/** Suspend the run for `duration` ms (durable). */
sleep?(duration: number): Promise<void>;
}
/** The workflow a message (or a resume) runs as: one at a time per channel conversation. */
export declare const CHANNEL_JOB_WORKFLOW = "agora.channel.job";
/** A question's timeout: runs on its own, so it never waits behind the conversation. */
export declare const CHANNEL_TIMER_WORKFLOW = "agora.channel.timer";
/** The dispatched step a phase runs as: `agora.channel.prepare`, `agora.channel.read`, … */
export declare const channelPhaseStep: (phase: ChannelPhase) => string;
/** A control-flow signal a workflow body surfaces (a suspend for a sleep, say) — not a failure. */
export declare const isControlFlowSignal: (error: unknown) => boolean;
/**
* 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 declare function registerChannelWorkflows(engine: ChannelWorkflowEngine, runnerFor: (channel: string) => ChannelRunner | undefined, server?: ChannelStepServer): void;
/** As durable runs on `engine` — see {@link registerChannelWorkflows}. */
export declare function durableExecutor(engine: ChannelWorkflowEngine): ChannelExecutor;
export interface ChannelRetryOptions {
/** Attempts per phase (the first included). Default 3. */
attempts?: number;
/** Delay before the second attempt; doubles each time (with jitter). Default 1 s. */
backoffMs?: number;
/** The longest delay between two attempts. Default 30 s. */
maxBackoffMs?: number;
/**
* Retry after this error? Default: not a refusal that retrying cannot change — a definite 4xx
* from the provider (`ChannelDeliveryError.definite`), an error carrying a 4xx `status`, a
* `QuotaBlockedError` — everything else yes.
*/
retryable?(error: unknown): boolean;
}
/** {@link ChannelRetryOptions.retryable}'s default. */
export declare function retryableByDefault(error: unknown): boolean;
/** Run `fn`, again after a growing delay while it fails with a retryable error. */
export declare function withRetries<T>(fn: () => Promise<T>, options?: ChannelRetryOptions): Promise<T>;
//# sourceMappingURL=executor.d.ts.map