UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

501 lines • 27 kB
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