@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
501 lines • 27 kB
TypeScript
import type { HttpContext } from '@adonisjs/core/http';
import { AgentService } from '../agent-service.js';
import type { AttachmentLimits } from '../attachment-limits.js';
import type { UiCapabilities } from '../genui/capabilities.js';
import type { Catalog } from '../genui/catalog.js';
import { type GenuiChannelBase, type GenuiChannels } from '../genui/channels.js';
import type { ActionProposal } from '../spi/action-proposal-store.js';
import type { AttachmentRef } from '../spi/attachment-staging.js';
import type { Actor, PageContext } from '../types.js';
import { type ChannelRetryOptions, type ChannelStepServer, type ChannelWorkflowEngine } from './executor.js';
import { type MarkdownTable } from './markdown.js';
import { type ChannelStore } from './store.js';
import { type ChannelComponent, type ChannelMediaRefusal, type ChannelTexts, type ChannelTextsOverrides } from './texts.js';
import type { ChannelAdapter, ChannelMediaFile, ChannelRequest, InboundMedia, InboundMessage, OutboundMedia, OutboundMessage } from './types.js';
/**
* What a channel turn needs from the agent — `AgentService` is one. The optional methods turn
* features on: proposals (buttons, outcomes), questions (`answer`), media (`stageAttachment`), and
* the Lucid default store (`lucidDatabase`).
*/
export interface ChannelTurnService extends Pick<AgentService, 'subscribe' | 'skip' | 'cancel' | 'actionProposalReply'>, Partial<Pick<AgentService, 'decideActionProposal' | 'listActionProposals' | 'actionProposalVocabulary' | 'actionApprovalMode' | 'answer' | 'attachmentLimits' | 'stageAttachment' | 'lucidDatabase' | 'drive'>> {
/**
* Start (or queue) a turn. The channel passes `{ textDecisions: false }`: it decides text
* decisions itself, only for the cards it delivered to the conversation.
*/
send(...args: Parameters<AgentService['send']>): ReturnType<AgentService['send']>;
}
/**
* Something the channel sends on the app's behalf (a hook's answer): text in the model's markdown
* (converted and split for the channel), text sent exactly as given (`raw` — a message the person
* forwards as is), or a file with a caption.
*/
export type ChannelReply = string | {
text: string;
raw?: boolean;
} | {
media: OutboundMedia;
/** In the model's markdown, converted. */
caption?: string;
/** What a channel without files sends instead. Default: the caption. */
fallbackText?: string;
};
/** What {@link ChannelHandleOptions.beforeTurn} decides. */
export type ChannelGate = 'continue' | 'stop' | undefined | {
reply: ChannelReply;
} | {
replies: ChannelReply[];
};
/** What a hook is told about the message being handled. */
export interface ChannelHookContext {
/** The adapter's name. */
channel: string;
conversation: string;
message: InboundMessage;
/**
* Every message the turn is made of, in the order they arrived — set only when `coalesce` grouped
* several into one turn (`message` is then one of them: the one the hook is about, or the first).
*/
coalesced?: readonly InboundMessage[];
}
/** One message about to go out — what {@link ChannelHandleOptions.canDeliver} checks. */
export interface ChannelDelivery {
channel: string;
conversation: string;
/** What goes out, exactly as the adapter will get it. */
outbound: OutboundMessage;
/**
* What it is: the turn's answer (`reply`), a proposal card, a relayed `outcome`, a `question`, a
* hook's answer before the turn (`gate`), or the channel's own text (`notice`).
*/
kind: 'reply' | 'card' | 'outcome' | 'question' | 'gate' | 'notice';
/** Who the conversation was answered as, when known. */
actor: Actor | null;
/** The account reference the outbound belongs to: the actor's id, or a relayed proposal's `actorRef`. */
actorRef: string | null;
/** The message being answered, when there is one. */
message: InboundMessage | null;
/** The proposal whose outcome is relayed (`kind: 'outcome'`). */
proposal: ActionProposal | null;
}
/** What a webhook request came to — {@link ChannelHandleOptions.onWebhook}. */
export interface ChannelWebhookEvent {
channel: string;
/**
* `accepted` → at least one new message taken; `duplicate` → only messages already taken;
* `ignored` → a verified body with no message to answer; `unauthorized` → `verify` refused it;
* `challenge` → a subscription check answered; `method_not_allowed`; `failed` → the messages
* could not be taken (store or engine down): answered `500`, the provider retries.
*/
status: 'accepted' | 'duplicate' | 'ignored' | 'unauthorized' | 'challenge' | 'method_not_allowed' | 'failed';
/** Why it was ignored (the adapter's reason — never message content), or what failed. */
reason?: string;
/** The provider's event name, when the body has one. */
event?: string;
/** New messages taken. */
accepted: number;
/** Messages already taken before (provider retries). */
duplicates: number;
/** The new messages, for counting — handled on their own, do not answer them here. */
messages: readonly InboundMessage[];
}
/** A turn has started — {@link ChannelHandleOptions.onTurnStarted}. */
export interface ChannelTurnStarted {
runId: string;
threadId: string;
actor: Actor;
message: InboundMessage;
/** The message waits in the thread's queue (its run starts later under `runId`). */
queued: boolean;
/**
* Every message the turn is made of, in order — set only when `coalesce` grouped several (then
* `message` is the first of them).
*/
coalesced?: readonly InboundMessage[];
/** From the webhook taking the (first) message to its turn starting, in ms. */
queueWaitMs?: number;
}
/**
* How a message's (or a resumed run's) handling ended in this process —
* {@link ChannelTurnEnded.outcome}:
* - `replied`: the turn's stream ended and what it said was delivered (possibly nothing — a turn
* that said nothing, or whose messages `canDeliver` dropped);
* - `failed`: the turn failed (`texts.failed` was sent), or the job itself failed after its retries
* (`error` is set; `onError` is told too);
* - `timeout`: the turn ran past `timeoutMs` and was cancelled (what it said so far was delivered);
* - `blocked`: the turn waits for a decision a text channel cannot make (blocking approvals);
* - `parked`: the turn asked a question and waits for the answer — a later message (or the question's
* timeout) resumes it, and that one ends with its own call;
* - `stopped`: no turn — the sender is unknown (`unknownSender`) or `beforeTurn` stopped it;
* - `handled`: no turn to read — an answer, a button press, a text decision, or nothing left to answer;
* - `interrupted`: the durable engine took the job away from this process mid-way (it goes on
* elsewhere, or later); this process will not deliver anything more for it.
*/
export type ChannelTurnOutcome = 'replied' | 'failed' | 'timeout' | 'blocked' | 'parked' | 'stopped' | 'handled' | 'interrupted';
/** This process is done with a message (or a resumed run) — {@link ChannelHandleOptions.onTurnEnded}. */
export interface ChannelTurnEnded {
/** The adapter's name. */
channel: string;
conversation: string;
/** Who the conversation was answered as — `null` for an unknown sender, or before it was known. */
actor: Actor | null;
/** The message handled — `null` when a run is resumed after its question (answered or timed out). */
message: InboundMessage | null;
/** The run read, when a turn was read (or resumed). */
runId: string | null;
threadId: string | null;
outcome: ChannelTurnOutcome;
/** What failed, for `outcome: 'failed'` from an error (after its retries). */
error?: unknown;
/**
* Every message the turn was made of, in order — set only when `coalesce` grouped several (then
* `message` is the first of them). A message grouped into another's turn is told about here, on
* that turn: its own job ends without a call of its own.
*/
coalesced?: readonly InboundMessage[];
/** The files the turn was sent with (after refusals and `transformInbound`), when it started one. */
attachments?: number;
/** From the webhook taking the (first) message to its turn starting, in ms — when a turn started. */
queueWaitMs?: number;
}
/** What {@link ChannelHandleOptions.prepareMedia} makes of a downloaded file. */
export type ChannelPreparedMedia = undefined
/** Attach this file instead (converted, resized…). */
| {
file: ChannelMediaFile;
}
/** Read the file as this text (a voice note's transcript): added to the message, not attached. */
| {
text: string;
}
/** Refuse it with `texts.mediaRefused(reason)`. */
| {
refuse: ChannelMediaRefusal;
};
/** The message as it goes to the agent — what {@link ChannelHandleOptions.transformInbound} edits. */
export interface ChannelInbound {
/** The text (with prepared media's text appended). */
text: string;
attachments: AttachmentRef[];
/**
* The messages a coalesced turn is made of, each with its own text (its caption, with its prepared
* media's text) and its own files — set only when `coalesce` grouped several. `text` is their
* texts joined in order and `attachments` their files in order; rebuild them from `parts` to,
* say, put each caption next to its file.
*/
parts?: ChannelInboundPart[];
}
/** One message of a coalesced turn — see {@link ChannelInbound.parts}. */
export interface ChannelInboundPart {
message: InboundMessage;
text: string;
attachments: AttachmentRef[];
}
/**
* Group a burst of messages into one turn — see {@link ChannelHandleOptions.coalesce}.
*/
export interface ChannelCoalesceOptions {
/**
* How long the conversation must stay quiet after its last message before the burst becomes a
* turn. Every message waits at least this long before it is answered.
*/
quietMs: number;
/**
* The longest a burst is held, from its first message being taken, however long the person keeps
* writing. Default: 30 s (or `quietMs`, when longer).
*/
maxWaitMs?: number;
/** The most messages one turn groups; the rest go to the next turn. Default 20. */
maxMessages?: number;
/**
* Which messages may be grouped — e.g. only files. One it returns `false` for is handled alone
* (and ends a burst). Default: every message but a control one (a button press, a text decision,
* an answer to a question), which is never grouped.
*/
when?(message: InboundMessage): boolean;
}
export interface ChannelHandleOptions {
/**
* The account the sender is — from `message.from` (a phone number, a Telegram id). `null` → the
* message is not answered by the agent ({@link unknownSender} / `texts.unknownSender`). This IS
* the authentication of every message, so map only senders the channel itself vouches for.
*/
actor(message: InboundMessage): Actor | null | Promise<Actor | null>;
/**
* A sender `actor()` mapped to nobody: what to answer (an onboarding step, a "link your number"
* link) — `null`/`undefined` → nothing. Default: `texts.unknownSender`.
*/
unknownSender?(message: InboundMessage, context: ChannelHookContext): ChannelReply | ChannelReply[] | null | undefined | Promise<ChannelReply | ChannelReply[] | null | undefined>;
/**
* Before anything else for a known sender — before its answers, button presses, text decisions
* and turns: `'continue'` (or nothing) goes on; `'stop'` ends here; `{ reply }` / `{ replies }`
* answers and ends here. For flows the app owns: terms to accept, an account to finish, a quota.
*/
beforeTurn?(message: InboundMessage, actor: Actor, context: ChannelHookContext): ChannelGate | Promise<ChannelGate>;
/**
* The thread this conversation continues; `null`/`undefined` → a new one, reported to
* {@link onThreadCreated} so you can store it.
*/
thread(actor: Actor, message: InboundMessage): string | null | undefined | Promise<string | null | undefined>;
/** A new thread was created for the conversation — remember it for {@link thread}. */
onThreadCreated?(threadId: string, actor: Actor, message: InboundMessage): void | Promise<void>;
/** A turn started (or was queued) for a message — record where it came from, start a meter. */
onTurnStarted?(turn: ChannelTurnStarted): void | Promise<void>;
/**
* This process is done handling a message, or a run resumed after its question — called ALWAYS,
* once per job this process ran, whatever it came to ({@link ChannelTurnOutcome}): a reply, a
* failure, a timeout, a turn that sent nothing, a sender stopped before any turn (after
* `adapter.acknowledge`), a durable job taken away mid-way. For what a process started for the
* message and must stop — a "typing…" presence, a meter. Per attempt: when a durable job runs
* again (a crash, a lease taken over by another process) each process that ran it is told. Not
* called for a question's timeout itself (the resumed run it starts is). Errors it throws are
* logged and ignored.
*/
onTurnEnded?(turn: ChannelTurnEnded): void | Promise<void>;
/**
* Checked before EVERY message the channel sends — replies, cards, questions, relayed outcomes.
* `false` → that message is dropped (e.g. the number was unlinked from the account mid-turn).
*/
canDeliver?(delivery: ChannelDelivery): boolean | Promise<boolean>;
/**
* The turn's page context. Default `{ kind: adapter.name }`. The handler adds `channel: { name,
* conversation }` to it — what a proposal's outcome is routed back by.
*/
pageContext?: PageContext | ((message: InboundMessage) => PageContext);
/** The agent that answers. Default: the thread's, else the configured default. */
agentName?: string;
/** The agent service. Default: `AgentService` from the container. */
service?: ChannelTurnService;
/**
* Where the channel's short-lived state lives: message ids already taken, what was delivered,
* questions waiting for an answer, cards sent, outcomes relayed. Default: `lucidChannelStore()`
* over the agent store's database when the agent store is Lucid, else this process's memory.
*/
store?: ChannelStore;
/**
* Process messages as `@adonis-agora/durable` runs: persisted before the `200`, one at a time per
* conversation, retried, and resumed after a crash (a reply never lost, never sent twice). Default:
* on when the agent runs durably (`durable: true` in `config/agent.ts`), on that engine. `false` →
* in this process (a restart loses what is in flight). An engine → that one.
*/
durable?: boolean | ChannelWorkflowEngine;
/**
* Where a durable job's phases run as DISPATCHED steps (`agora.channel.prepare`, `.act`, `.read`,
* …), so any process that registered the channel runs them and any process continues the job: a
* durable `Transport` (anything with `handle(name, fn)`). Default: the transport of the app's
* durable engine (from the container) when the channel runs on the agent's engine, or the engine's
* own `handleStep`. `false`, or none found → each phase runs in the process that runs the
* workflow (`ctx.localStep`).
*/
durableSteps?: ChannelStepServer | false;
/**
* Group a burst of messages of one conversation into ONE turn — "oi" / "tudo bem?" / "queria saber
* X", or ten files sent in a row: a message waits until the conversation has been quiet for
* `quietMs` (at most `maxWaitMs`, at most `maxMessages` messages), then the burst is one turn —
* texts joined in order, every file attached (each message's files prepared and refused as
* alone). Control messages are never grouped: a button press, a text decision ("yes") or an
* answer to a question is handled on its own, in order, and ends a burst. Messages that arrive
* while a grouped turn runs go to the next turn. With a durable engine the wait is the engine's
* durable sleep (any process resumes it); inline, a timer. Off by default.
*/
coalesce?: ChannelCoalesceOptions;
/** How each phase of a message is retried when it fails. */
retry?: ChannelRetryOptions;
/** How long a message id (and what was delivered for it) is remembered. Default 24 h. */
dedupeTtlMs?: number;
/** Stop waiting for a turn after this long (and cancel it). Default 5 minutes. */
timeoutMs?: number;
/**
* After a proposal is approved, wait this long for it to execute and relay its outcome. `0` → do
* not wait (`channels.onSettled` relays it once it runs). Default 60 s.
*/
outcomeTimeoutMs?: number;
/** How long a question waits for its answer before the agent proceeds without it. Default 30 min. */
questionTimeoutMs?: number;
/**
* What the channel says on its own. Omitted parts come from {@link channelTextsFor}: Brazilian
* Portuguese when the agent's `actionProposalText` is `ptBrActionProposalText`, else English. A
* function picks them per message (the actor's locale) — `actor` is `null` before it is known and
* for a relayed outcome (then `proposal` is set).
*/
texts?: ChannelTextsOverrides | ((context: {
actor: Actor | null;
message: InboundMessage | null;
proposal?: ActionProposal;
}) => ChannelTextsOverrides | Promise<ChannelTextsOverrides>);
/**
* Accept "always in this conversation" in a text decision. Default `true`; `false` → it is refused
* with `texts.rememberRefused` (every action needs its own confirmation here).
*/
allowRemember?: boolean;
/**
* What the turn may draw. Default `{ components: [] }`: every component arrives as its
* `fallbackText`. Allow some with {@link renderComponent} to send them as files. With
* `genui({ channels })` configured for this channel ({@link ChannelAdapter.kind}, else `default`)
* the default is the channel's own: the components it can draw, delivered natively.
*/
uiCapabilities?: UiCapabilities;
/**
* The genui setup this channel draws with — default: the app's (`AgentGenui` from the container).
* With `channels` configured for this channel, components arrive natively (their `channels`
* conversion: text, reply buttons, lists, images; their text summary otherwise), and a pressed
* button is the user's next turn, as a UI action. `false` → never (every component as its text).
* With a `service` handed in, the container is not read: pass `genui` too.
*/
genui?: ChannelGenui | false;
/**
* A component the turn drew: what to send for it — a file (a chart as an image), text, several —
* or `null`/`undefined` for its `fallbackText`. Rendered components are sent when the turn ends (or
* asks a question), before its text; a later one with the same id replaces it; a failed turn sends
* none.
*/
renderComponent?(component: ChannelComponent, context: {
actor: Actor;
conversation: string;
runId: string;
rendered: number;
}): ChannelReply | ChannelReply[] | null | undefined | Promise<ChannelReply | ChannelReply[] | null | undefined>;
/**
* A table in the turn's reply text (GitHub markdown): what to send in its place — a file (the
* table as an image), text, several — or `null`/`undefined` to keep it in the text, where the
* channel's markdown turns it into a list. The text around it goes as usual: before the table's
* replies, and after them.
*/
renderTable?(table: MarkdownTable, context: {
actor: Actor;
conversation: string;
runId: string;
}): ChannelReply | ChannelReply[] | null | undefined | Promise<ChannelReply | ChannelReply[] | null | undefined>;
/**
* The attachment limits for one file — e.g. let audio through to {@link prepareMedia}, which
* transcribes it. Default: the agent's.
*/
mediaLimits?(limits: AttachmentLimits | null, media: InboundMedia): AttachmentLimits | null;
/**
* A downloaded file, before it is attached: attach another (`{ file }`), read it as text (`{ text }`
* — a voice note's transcript), refuse it, or (nothing) attach it as is.
*/
prepareMedia?(file: ChannelMediaFile, media: InboundMedia, context: ChannelHookContext & {
actor: Actor;
}): ChannelPreparedMedia | Promise<ChannelPreparedMedia>;
/**
* The message as it goes to the agent, last: add a note about the attachments, a default question
* for an image without a caption… Text decisions are read from the result.
*/
transformInbound?(inbound: ChannelInbound, context: ChannelHookContext & {
actor: Actor;
threadId: string | null;
}): ChannelInbound | Promise<ChannelInbound>;
/**
* What an executed proposal tells the person, in order — the outcome, then follow-ups (a message
* to forward, sent `raw` and alone). Relayed once. `null`/`undefined` → the default (`text`: the
* proposal's own outcome text, else `texts.actionSucceeded` / `texts.actionFailed`); `[]` → nothing.
*/
formatOutcome?(proposal: ActionProposal, context: {
text: string;
texts: ChannelTexts;
}): ChannelReply[] | null | undefined | Promise<ChannelReply[] | null | undefined>;
/** What every webhook request came to — for counters and logs, without parsing the body again. */
onWebhook?(event: ChannelWebhookEvent): void | Promise<void>;
/** A message whose handling failed (after its retries). Default: `console.error`. */
onError?(error: unknown, message: InboundMessage): void;
}
/** What a channel draws generative UI with — `AgentGenui` is one. */
export interface ChannelGenui {
catalog: Catalog;
channels?: GenuiChannels;
base?: GenuiChannelBase;
}
/** The route handler `channels.handle()` returns. */
export interface ChannelRouteHandler {
(ctx: HttpContext): Promise<void>;
/**
* Resolves once every message this handler took has been handled — for tests and shutdown. With
* a durable engine: once their runs ended.
*/
drain(): Promise<void>;
/** Handle a request built by hand (another framework, a test). */
handleRequest(request: ChannelRequest): Promise<{
status: number;
body: unknown;
contentType?: string;
}>;
}
/** The button ids for a proposal: what {@link ChannelRouteHandler} maps back to its decision. */
export declare function proposalButtonIds(proposalId: string): {
approve: string;
reject: string;
};
/** Where a turn's output goes back to — `pageContext.channel`, recorded with every proposal. */
export interface ChannelAddress {
name: string;
conversation: string;
/** The adapter's {@link ChannelAdapter.kind}, when it has one — what `turnChannel` reads first. */
kind?: string;
/**
* How many reply buttons and list rows the adapter shows (its capabilities): what a tool result's
* `delivered` describes choices by, the way the handler will send them.
*/
choices?: {
buttons: number;
lists: number;
};
}
/**
* The webhook route for one channel. Each request is verified (`401` when it is not the provider's),
* parsed, deduplicated by the provider's message id, taken (persisted, with a durable engine) and
* acknowledged with `200`; the message is handled after the response — one at a time per
* conversation, each phase retried — so a slow model never makes the provider retry. Then:
*
* - an unknown sender gets `unknownSender`; a known one goes through `beforeTurn`;
* - an answer to a question the turn asked resumes it;
* - a press on a proposal's button decides that proposal (`via` = the adapter's name);
* - a text decision ("yes", "no #ID", a button label) decides a card delivered to THIS conversation
* — never a proposal made elsewhere (`texts.noPendingConfirmation`);
* - media are downloaded (`prepareMedia`) and attached, or refused with a text;
* - the message (`transformInbound`) starts a turn with the channel's capabilities;
* - the reply is converted to the channel's markdown, split at its length limit, and sent — each
* message once, even when the work is retried or resumed after a crash;
* - a proposal the turn left pending is sent with Confirm/Cancel buttons (or a text instruction);
* - an approved proposal's outcome is relayed once it executed (see `outcomeTimeoutMs`, and
* `channels.onSettled` for one that runs later).
*
* Wants `actionApprovalMode: 'independent'`: a blocking approval holds the turn open until someone
* decides it in the app, so the channel sends what it has and `texts.blockingApproval`.
*
* Registers the adapter under its `name` for `channels.onSettled` and the durable jobs — names must
* be unique, and a process that resumes durable channel jobs must create the same handlers at boot.
*/
export declare function handleChannel(adapter: ChannelAdapter, options: ChannelHandleOptions): ChannelRouteHandler;
/**
* `actionProposalWorker.onSettled` for text channels: relays an executed proposal's outcome to the
* conversation it was proposed in, through the adapter registered under the recorded channel name
* (with its `formatOutcome`, `texts` and `canDeliver`). Proposals from other surfaces are ignored.
* Safe next to the handler's own wait: an outcome is relayed once.
*
* ```ts
* // config/agent.ts
* actionProposalWorker: { onSettled: channels.onSettled }
* ```
*/
export declare function relayChannelOutcome(proposal: ActionProposal): Promise<void>;
export declare const channels: {
/** See {@link handleChannel}. */
handle: typeof handleChannel;
/** See {@link relayChannelOutcome}. */
onSettled: typeof relayChannelOutcome;
/** Relay one proposal's outcome now; `true` when it was sent. */
deliverOutcome: (proposal: ActionProposal, service?: ChannelTurnService) => Promise<boolean>;
/** The adapter registered under `name` (by `channels.handle`), if any. */
adapter: (name: string) => ChannelAdapter | undefined;
/**
* Deliver a run started outside the channel (a turn continued on the server, e.g. after a sign-in
* completed elsewhere) to the conversation `address` names (`pageContext.channel`). `false` when
* no channel of that name is registered in this process.
*/
follow: (address: unknown, run: {
runId: string;
threadId: string;
actor: Actor;
}) => Promise<boolean>;
};
//# sourceMappingURL=handler.d.ts.map