@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
1,906 lines • 93 kB
JavaScript
import { createHash } from 'node:crypto';
import { DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY, parseTextActionProposalCommand, } from '../action-proposal-text.js';
import { AgentService, AttachmentRefusedError } from '../agent-service.js';
import { onAgentEngine, registeredAgentEngine, registeredAgentStepServer, } from '../durable/agent-run-context.js';
import { uiActionText } from '../genui/actions.js';
import { channelButtonAction, renderChannelMessages, resolveGenuiChannel, } from '../genui/channels.js';
import { AgentGenui } from '../genui/factory.js';
import { DEFAULT_CONFIRM_VERB } from '../tool-presentation.js';
import { durableExecutor, inlineExecutor, inlineIo, isControlFlowSignal, jobId, registerChannelWorkflows, withRetries, } from './executor.js';
import { ChannelMediaTooLargeError } from './http.js';
import { splitTables, toChannelMarkdown } from './markdown.js';
import { formatChannelQuestion, parseChannelAnswer } from './questions.js';
import { splitMessage } from './split.js';
import { InMemoryChannelStore, lucidChannelStore } from './store.js';
import { channelTextsFor, DEFAULT_CHANNEL_TEXTS, mergeTexts, } from './texts.js';
const BUTTON_ID = /^agora:(approve|reject):([^\s]+)$/;
/** A component's button (or list entry): `ui:<ref>`, short enough for Telegram's 64-byte callback data. */
const UI_BUTTON_ID = /^ui:([A-Za-z0-9_-]{6,32})$/;
/** How long a component's buttons stay pressable, and how many per conversation are remembered by label. */
const UI_BUTTON_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_UI_BUTTONS = 40;
/** The tail of a proposal id a button carries — Telegram's `callback_data` holds 64 bytes. */
const BUTTON_REF_LENGTH = 32;
const OUTCOME_POLL_MS = 500;
/** How often a durable job reading a turn looks for a part of it still waiting for a worker. */
const DRIVE_POLL_MS = 500;
/** How long "this outcome was relayed" is remembered. */
const OUTCOME_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const buttonRef = (proposalId) => proposalId.slice(-BUTTON_REF_LENGTH);
/** How long the proposal cards sent to a conversation are remembered, and how many. */
const CARDS_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_CARDS = 20;
const foldLabel = (label) => label.trim().toLowerCase();
/**
* The approve button's label on a proposal card: the tool's own `confirm.verb` ("Refund"), else the
* channel's localized `texts.approve` — the library's generic English verb never reaches a person
* on a channel that words its own.
*/
const approveLabel = (proposal, texts) => {
const verb = proposal.confirmation?.verb?.trim();
return verb === undefined || verb === '' || verb === DEFAULT_CONFIRM_VERB ? texts.approve : verb;
};
/** The button ids for a proposal: what {@link ChannelRouteHandler} maps back to its decision. */
export function proposalButtonIds(proposalId) {
const ref = buttonRef(proposalId);
return { approve: `agora:approve:${ref}`, reject: `agora:reject:${ref}` };
}
function requestOf(ctx) {
return {
method: ctx.request.method().toUpperCase(),
url: ctx.request.url(true),
header: (name) => ctx.request.header(name),
params: (ctx.params ?? {}),
body: ctx.request.body(),
rawBody: ctx.request.raw(),
};
}
/** `AgentService` from the app's container, for code that runs outside a request. */
async function containerService() {
const { default: app } = await import('@adonisjs/core/services/app');
return (await app.container.make(AgentService));
}
const registry = new Map();
/** The job runner of the channel registered under `channel` in this process. */
const runnerFor = (channel) => registry.get(channel)?.runner;
let following = false;
/** Register the channel workflows on the agent's durable engine, now or once it is wired. */
function followAgentEngine() {
if (following)
return;
following = true;
onAgentEngine((engine) => registerChannelWorkflows(engine, runnerFor, registeredAgentStepServer()));
}
function record(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value
: undefined;
}
/** `image/jpeg` → `jpeg`, for a file that arrives without a name. */
const extensionOf = (contentType) => (contentType.split('/')[1] ?? 'bin').split(/[;+]/)[0] ?? 'bin';
/** Where a natively drawn component sits in a turn's text, until it is sent. */
const NATIVE_MARK = '\u0000agora-native\u0000';
const replyList = (value) => value === null || value === undefined ? [] : Array.isArray(value) ? value : [value];
/** The component a frame carries (a live one, or a replayed `ui` event), else `null`. */
function componentOf(frame, position) {
// A preview of a layout the model is still writing: a channel gets the final component (or its
// fallback text) and nothing before it.
if ((frame.t === 'component' && frame.partial === true) ||
(frame.t === 'event' && frame.event.kind === 'ui' && frame.event.partial === true)) {
return null;
}
if (frame.t === 'component') {
return {
id: frame.id ?? `ui:${position}`,
name: frame.name,
data: frame.data,
version: frame.version ?? 1,
...(frame.componentVersions !== undefined
? { componentVersions: frame.componentVersions }
: {}),
...(frame.fallbackText !== undefined ? { fallbackText: frame.fallbackText } : {}),
};
}
if (frame.t === 'event' && frame.event.kind === 'ui') {
const { event } = frame;
return {
id: event.id,
name: event.component,
data: event.props,
version: event.version ?? 1,
...(event.componentVersions !== undefined
? { componentVersions: event.componentVersions }
: {}),
...(event.fallbackText !== undefined ? { fallbackText: event.fallbackText } : {}),
};
}
return null;
}
/**
* Keep executing, in this process, what of `runId` waits for a worker (`service.drive`), until
* stopped. A durable channel job runs inside a worker's tick, and the tick ends only when the job
* does: a turn the job started with a dispatcher that only persists it — or a delegate that turn
* started — would wait for the next tick, and the job for the turn, until the read times out.
*/
function keepDriving(drive, runId) {
let stopped = false;
let timer;
const pass = async () => {
await drive(runId).catch(() => { });
if (stopped)
return;
timer = setTimeout(() => void pass(), DRIVE_POLL_MS);
timer.unref?.();
};
void pass();
return () => {
stopped = true;
clearTimeout(timer);
};
}
/**
* 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 function handleChannel(adapter, options) {
const dedupeTtlMs = options.dedupeTtlMs ?? 24 * 60 * 60 * 1000;
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
const outcomeTimeoutMs = options.outcomeTimeoutMs ?? 60_000;
const questionTimeoutMs = options.questionTimeoutMs ?? 30 * 60 * 1000;
const { capabilities } = adapter;
const buttons = (capabilities.buttons ?? 0) >= 2;
const name = adapter.name;
/** What the channel is (`whatsapp`, `telegram`) — the turn's channel, whatever it is named. */
const kind = adapter.kind ?? adapter.name;
let resolvedGenui;
/** The genui setup, and this channel's options in it — `null` when it draws nothing natively. */
const genuiFor = () => {
resolvedGenui ??= (async () => {
if (options.genui === false)
return null;
if (options.genui !== undefined)
return options.genui;
// A service handed in (a test, another framework): no container to read the app's genui from.
if (options.service !== undefined)
return null;
try {
const { default: app } = await import('@adonisjs/core/services/app');
if (!app.container.hasBinding(AgentGenui))
return null;
return (await app.container.make(AgentGenui));
}
catch {
return null;
}
})();
return resolvedGenui.then((genui) => {
if (genui === null || genui.channels === undefined)
return null;
const channel = resolveGenuiChannel(genui.base ?? {}, genui.channels, kind);
return channel.configured ? { genui, channel } : null;
});
};
let resolvedService;
const serviceFor = (given) => {
if (options.service)
return Promise.resolve(options.service);
if (given)
return Promise.resolve(given);
resolvedService ??= containerService().catch((error) => {
resolvedService = undefined;
throw error;
});
return resolvedService;
};
let resolvedStore;
/** The configured store, else Lucid over the agent store's database, else memory — chosen once. */
const storeFor = (service) => {
resolvedStore ??= (async () => {
if (options.store)
return options.store;
const agent = options.service ?? service ?? (await containerService().catch(() => undefined));
const db = agent?.lucidDatabase?.() ?? null;
return db === null ? new InMemoryChannelStore() : lucidChannelStore(db);
})();
return resolvedStore;
};
/** The texts for one context, over the defaults in the language of the service's vocabulary. */
const textsFor = async (service, context) => {
const base = channelTextsFor(service.actionProposalVocabulary?.() ?? null);
const overrides = typeof options.texts === 'function' ? await options.texts(context) : options.texts;
return mergeTexts(overrides, base);
};
const questionKey = (conversation) => `${name}:question:${conversation}`;
const cardsKey = (conversation) => `${name}:cards:${conversation}`;
const answeredKey = (runId, toolCallId) => `${name}:answered:${runId}:${toolCallId}`;
/**
* Settle a question once — by its answer, by its timeout, or skipped: `true` when `owner` settles
* it (now, or in an earlier run of the same phase), `false` when something else did first.
*/
const settleQuestion = async (store, runId, toolCallId, owner) => {
const key = answeredKey(runId, toolCallId);
if (await store.claim(key, dedupeTtlMs)) {
await store.set(key, owner, dedupeTtlMs);
return true;
}
return (await store.get(key)) === owner;
};
/**
* Sends to one conversation, each message at most once: every message takes a numbered slot
* under `key` in the store before it goes out, so a phase that runs again (a retry, a crash
* recovery) skips what already went. A failed send frees its slot, so the retry sends it.
*/
const outbox = (store, key, conversation, who) => {
let next = 0;
const send = async (outbound, kind) => {
const slot = `${key}:${next++}`;
if (!(await store.claim(slot, dedupeTtlMs)))
return;
try {
if (options.canDeliver &&
!(await options.canDeliver({
channel: name,
conversation,
outbound,
kind,
actor: who.actor,
actorRef: who.actorRef ?? who.actor?.id ?? null,
message: who.message,
proposal: who.proposal ?? null,
})))
return;
await adapter.send(conversation, outbound);
}
catch (error) {
await Promise.resolve(store.delete(slot)).catch(() => { });
throw error;
}
};
/** Text in the model's markdown: converted, split. */
const text = async (value, kind) => {
for (const piece of splitMessage(toChannelMarkdown(value, capabilities.markdown), capabilities.maxLength))
await send({ text: piece }, kind);
};
/** A hook's reply. */
const reply = async (value, kind) => {
if (typeof value === 'string')
return text(value, kind);
if ('media' in value) {
const caption = value.caption ?? '';
if (capabilities.media === true) {
const max = capabilities.maxCaptionLength ?? capabilities.maxLength;
return send({
text: toChannelMarkdown(caption, capabilities.markdown).slice(0, max),
media: value.media,
}, kind);
}
const fallback = value.fallbackText ?? caption;
if (fallback.trim() !== '')
return text(fallback, kind);
return;
}
if (value.raw === true) {
for (const piece of splitMessage(value.text, capabilities.maxLength))
await send({ text: piece }, kind);
return;
}
return text(value.text, kind);
};
return { send, text, reply };
};
const commandsFor = (service, proposalId) => {
const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
const suffix = proposalId !== null ? ` #${proposalId}` : '';
return {
approve: `${vocabulary.approve[0] ?? 'yes'}${suffix}`,
reject: `${vocabulary.reject[0] ?? 'no'}${suffix}`,
};
};
/** Remember a proposal card sent to `conversation` — the only proposals it can decide. */
const rememberCard = async (store, conversation, proposalId) => {
const key = cardsKey(conversation);
const saved = await store.get(key);
const ref = buttonRef(proposalId);
const refs = saved === null ? [] : JSON.parse(saved);
const next = [...refs.filter((known) => known !== ref), ref].slice(-MAX_CARDS);
await store.set(key, JSON.stringify(next), CARDS_TTL_MS);
};
const cardsOf = async (store, conversation) => {
const saved = await store.get(cardsKey(conversation));
return new Set(saved === null ? [] : JSON.parse(saved));
};
const uiButtonKey = (ref) => `${name}:ui:${ref}`;
const uiButtonsKey = (conversation) => `${name}:uibuttons:${conversation}`;
/**
* Remember a component's buttons — by id for the press, and by label for a provider that forwards
* only the label (Whatsmiau). Each id is derived from the run, the component and its place, so a
* phase that runs again names the same buttons.
*/
const rememberUiButtons = async (store, conversation, seed, message, entries) => {
const ids = [];
const index = await store.get(uiButtonsKey(conversation));
const known = index === null ? [] : JSON.parse(index);
for (const [position, button] of entries.entries()) {
const ref = createHash('sha256')
.update(`${seed}:${position}`)
.digest('base64url')
.slice(0, 16);
const saved = {
conversation,
button,
...(message.componentId !== undefined ? { componentId: message.componentId } : {}),
...(message.title !== undefined ? { title: message.title } : {}),
};
await store.set(uiButtonKey(ref), JSON.stringify(saved), UI_BUTTON_TTL_MS);
ids.push(`ui:${ref}`);
known.push(ref);
}
await store.set(uiButtonsKey(conversation), JSON.stringify([...new Set(known)].slice(-MAX_UI_BUTTONS)), UI_BUTTON_TTL_MS);
return ids;
};
/** The component button a press (or, for a provider without ids, its label) names, if any. */
const pressedUiButton = async (store, message) => {
const match = message.buttonId === undefined ? null : UI_BUTTON_ID.exec(message.buttonId);
if (match !== null) {
const saved = await store.get(uiButtonKey(match[1] ?? ''));
const button = saved === null ? null : JSON.parse(saved);
return button !== null && button.conversation === message.conversation ? button : null;
}
if (message.buttonId !== undefined || message.buttonWithoutId !== true)
return null;
const index = await store.get(uiButtonsKey(message.conversation));
const label = foldLabel(message.text);
for (const ref of (index === null ? [] : JSON.parse(index)).reverse()) {
const saved = await store.get(uiButtonKey(ref));
const button = saved === null ? null : JSON.parse(saved);
if (button !== null && foldLabel(button.button.label) === label)
return button;
}
return null;
};
/**
* One natively drawn message out: an image with its caption, text with reply buttons, a list (as
* reply buttons when it fits and the channel has no lists, as numbered text when neither does), or
* text — in the channel's markdown and limits.
*/
const sendNative = async (store, out, conversation, seed, message) => {
const markdown = (text) => toChannelMarkdown(text, capabilities.markdown);
const text = message.text ?? '';
if (message.image !== undefined) {
const { image } = message;
await out.reply({
media: {
kind: 'image',
...(image.url !== undefined ? { url: image.url } : {}),
...(image.data !== undefined ? { data: Buffer.from(image.data) } : {}),
contentType: image.contentType ?? 'image/png',
},
caption: text,
fallbackText: text,
}, 'reply');
}
const choices = message.list?.items ?? message.buttons ?? [];
if (choices.length === 0) {
if (message.image === undefined && text.trim() !== '')
await out.text(text, 'reply');
return;
}
const body = message.image === undefined ? text : '';
const numbered = [
body,
...(message.list?.title !== undefined ? [`*${message.list.title}*`] : []),
choices
.map((choice, index) => `${index + 1}. ${choice.label}${choice.description ? ` — ${choice.description}` : ''}`)
.join('\n'),
]
.filter((part) => part.trim() !== '')
.join('\n\n');
const maxButtons = capabilities.buttons ?? 0;
const asButtons = message.list === undefined || (capabilities.lists ?? 0) === 0;
if (asButtons && choices.length <= maxButtons) {
const ids = await rememberUiButtons(store, conversation, seed, message, choices);
await out.send({
text: markdown(body.trim() === '' ? (message.title ?? '…') : body).slice(0, capabilities.maxLength),
buttons: choices.map((choice, index) => ({ id: ids[index] ?? '', label: choice.label })),
fallbackText: markdown(numbered).slice(0, capabilities.maxLength),
}, 'reply');
return;
}
const maxRows = capabilities.lists ?? 0;
if (maxRows > 0) {
const rows = choices.slice(0, maxRows);
const ids = await rememberUiButtons(store, conversation, seed, message, rows);
await out.send({
text: markdown(body.trim() === '' ? (message.title ?? '…') : body).slice(0, capabilities.maxLength),
list: {
button: message.list?.button ?? 'Options',
...(message.list?.title !== undefined ? { title: message.list.title } : {}),
rows: rows.map((row, index) => ({
id: ids[index] ?? '',
title: row.label,
...(row.description !== undefined ? { description: row.description } : {}),
})),
},
fallbackText: markdown(numbered).slice(0, capabilities.maxLength),
}, 'reply');
return;
}
// Neither buttons nor lists: the choices as numbered text.
await out.text(numbered, 'reply');
};
const sendProposal = async (service, store, texts, out, conversation, proposal, withId) => {
const summary = texts.proposal(proposal);
const link = proposal.confirmation?.link;
if (link !== undefined && link !== '') {
// The action is opening the link: the card, the link in full, what to do — no buttons, no
// reply commands, no validity (finishing there decides it; a "yes" here decides nothing).
const card = toChannelMarkdown([
summary.includes(link) ? summary : `${summary}\n${link}`,
texts.linkInstruction ?? DEFAULT_CHANNEL_TEXTS.linkInstruction,
proposal.expiresAt !== undefined
? (texts.linkValidity ?? DEFAULT_CHANNEL_TEXTS.linkValidity)?.(Math.max(1, Math.round((proposal.expiresAt - Date.now()) / 60_000)))
: undefined,
]
.filter((part) => part !== undefined && part !== '')
.join('\n\n'), capabilities.markdown);
for (const piece of splitMessage(card, capabilities.maxLength))
await out.send({ text: piece }, 'card');
return;
}
const footer = typeof texts.footer === 'function' ? texts.footer(proposal) : texts.footer;
const instruction = texts.instruction(commandsFor(service, withId ? proposal.id : null));
const asText = toChannelMarkdown([summary, instruction, footer]
.filter((part) => part !== undefined && part !== '')
.join('\n\n'), capabilities.markdown);
// Remembered before it goes: a "yes" can only decide a card this conversation was sent.
await rememberCard(store, conversation, proposal.id);
if (!buttons) {
for (const piece of splitMessage(asText, capabilities.maxLength))
await out.send({ text: piece }, 'card');
return;
}
const ids = proposalButtonIds(proposal.id);
await out.send({
text: toChannelMarkdown(summary, capabilities.markdown).slice(0, capabilities.maxLength),
buttons: [
{ id: ids.approve, label: approveLabel(proposal, texts) },
{ id: ids.reject, label: texts.reject },
],
fallbackText: asText.slice(0, capabilities.maxLength),
instruction: toChannelMarkdown(instruction, capabilities.markdown),
...(footer !== undefined && footer !== ''
? { footer: toChannelMarkdown(footer, capabilities.markdown) }
: {}),
}, 'card');
};
/** What an executed proposal tells the person, in order; `null` when it did not execute. */
const outcomeReplies = async (proposal, texts) => {
const status = proposal.execution?.status;
if (status !== 'failed' && status !== 'succeeded')
return null;
// What its presentation said (`present` / `emitUi` text), else a plain confirmation.
const text = status === 'failed'
? texts.actionFailed
: proposal.outcome?.text?.trim() || texts.actionSucceeded;
const formatted = await options.formatOutcome?.(proposal, { text, texts });
return formatted ?? [text];
};
/** Send an executed proposal's outcome unless it was sent already (by this or another replica). */
const relay = async (proposal, conversation, given, who) => {
const service = await serviceFor(given);
const store = await storeFor(service);
const status = proposal.execution?.status;
if (status !== 'failed' && status !== 'succeeded')
return false;
if (!(await store.claim(`${name}:outcome:${proposal.id}`, OUTCOME_TTL_MS)))
return false;
const texts = await textsFor(service, {
actor: who?.actor ?? null,
message: who?.message ?? null,
proposal,
});
const replies = (await outcomeReplies(proposal, texts)) ?? [];
const out = outbox(store, `${name}:out:outcome:${proposal.id}`, conversation, {
actor: who?.actor ?? null,
actorRef: who?.actor?.id ?? proposal.actorRef ?? null,
message: who?.message ?? null,
proposal,
});
for (const reply of replies)
await out.reply(reply, 'outcome');
return true;
};
/** Wait for an approved proposal to run, then relay what it said (or that it failed). */
const relayOutcome = async (service, actor, threadId, proposalId, message) => {
if (outcomeTimeoutMs <= 0 || !service.listActionProposals)
return;
const deadline = Date.now() + outcomeTimeoutMs;
while (Date.now() < deadline) {
const current = (await service.listActionProposals(actor, threadId)).find((proposal) => proposal.id === proposalId);
if (current?.decision !== 'approved')
return;
const status = current.execution?.status;
if (status === 'succeeded' || status === 'failed') {
await relay(current, message.conversation, service, { actor, message });
return;
}
await new Promise((resolve) => setTimeout(resolve, OUTCOME_POLL_MS));
}
};
/**
* A button press the provider forwarded without its id — only the label (Whatsmiau): the id of
* the one card it can have come from — a card sent to this conversation whose proposal is still
* pending. Several such cards → `undefined`: never guess; the label then goes on as a text
* decision, which asks for the `#id`.
*/
const recoverButtonId = async (service, store, texts, actor, threadId, message) => {
if (message.buttonId !== undefined || message.buttonWithoutId !== true)
return undefined;
if (threadId === null || !service.listActionProposals)
return undefined;
const label = foldLabel(message.text);
const fixed = label === foldLabel(texts.approve)
? 'approve'
: label === foldLabel(texts.reject)
? 'reject'
: undefined;
const refs = await cardsOf(store, message.conversation);
if (refs.size === 0)
return undefined;
const live = (await service.listActionProposals(actor, threadId)).filter((proposal) => proposal.decision === 'pending' && refs.has(buttonRef(proposal.id)));
// The approve button may carry the tool's own verb ("Refund"): a label matching it approves
// the one card that showed it.
const candidates = fixed !== undefined
? live
: live.filter((proposal) => foldLabel(approveLabel(proposal, texts)) === label);
const action = fixed ?? (candidates.length > 0 ? 'approve' : undefined);
if (action === undefined)
return undefined;
const only = candidates.length === 1 ? candidates[0] : undefined;
return only === undefined ? undefined : `agora:${action}:${buttonRef(only.id)}`;
};
/** Decide one proposal by a press or a text, answer, and relay its outcome when it ran. */
const decide = async (service, out, actor, threadId, proposalId, decision, remember, message) => {
if (!service.decideActionProposal)
return;
const result = await service.decideActionProposal(actor, threadId, proposalId, decision, remember ? { remember: true } : {}, name);
await out.text(service.actionProposalReply(result, decision), 'notice');
if (result.status === 'applied' && decision === 'approved')
await relayOutcome(service, actor, threadId, proposalId, message);
};
/** A press on one of our proposal buttons: decide it. `false` → not one of ours. */
const pressButton = async (service, out, actor, threadId, message) => {
const match = message.buttonId === undefined ? null : BUTTON_ID.exec(message.buttonId);
if (!match || !service.decideActionProposal || !service.listActionProposals)
return false;
const decision = match[1] === 'approve' ? 'approved' : 'rejected';
const ref = match[2] ?? '';
const proposal = threadId === null
? undefined
: (await service.listActionProposals(actor, threadId)).find((candidate) => buttonRef(candidate.id) === ref);
if (threadId === null || !proposal) {
await out.text(service.actionProposalReply({ status: 'not_found' }, decision), 'notice');
return true;
}
await decide(service, out, actor, threadId, proposal.id, decision, false, message);
return true;
};
/**
* A text decision ("yes", "no #ID", a button label the provider forwarded alone), scoped to the
* cards this conversation was sent. `false` → not a decision: an ordinary message.
*/
const decideByText = async (service, store, texts, out, actor, threadId, message, text) => {
if (threadId === null || !service.listActionProposals || !service.decideActionProposal)
return false;
const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
const command = parseTextActionProposalCommand(text, vocabulary);
if (command.status !== 'command')
return false;
const proposals = await service.listActionProposals(actor, threadId);
const cards = await cardsOf(store, message.conversation);
const deliverable = proposals.filter((proposal) => proposal.decision === 'pending' && cards.has(buttonRef(proposal.id)));
const named = command.proposalId;
if (named !== undefined) {
const target = deliverable.find((proposal) => proposal.id === named);
// An `#ID` naming no proposal of the thread is an ordinary message.
if (target === undefined && !proposals.some((proposal) => proposal.id === named))
return false;
if (target === undefined) {
await out.text(texts.noPendingConfirmation, 'notice');
return true;
}
if (command.remember && options.allowRemember === false) {
await out.text(texts.rememberRefused, 'notice');
return true;
}
await decide(service, out, actor, threadId, target.id, command.decision, command.remember, message);
return true;
}
if (deliverable.length === 0) {
// Nothing pending anywhere: "yes" is just a word in the conversation.
if (!proposals.some((proposal) => proposal.decision === 'pending'))
return false;
await out.text(texts.noPendingConfirmation, 'notice');
return true;
}
if (deliverable.length > 1) {
await out.text(texts.ambiguousDecision(deliverable.map((proposal) => proposal.id), commandsFor(service, null)), 'notice');
return true;
}
if (command.remember && options.allowRemember === false) {
await out.text(texts.rememberRefused, 'notice');
return true;
}
const [only] = deliverable;
if (only === undefined)
return false;
await decide(service, out, actor, threadId, only.id, command.decision, command.remember, message);
return true;
};
const askNext = (texts, out, pending) => {
const question = pending.questions[pending.index];
if (!question)
return Promise.resolve();
return out.text(formatChannelQuestion(question, {
index: pending.index,
total: pending.questions.length,
...(pending.preamble !== undefined ? { preamble: pending.preamble } : {}),
}, texts.questions), 'question');
};
/**
* The person's message answers the question in front of them; the last one resumes the run.
* Returns the run to read on, once all are answered.
*/
const answerQuestion = async (service, store, texts, out, actor, message, pending) => {
if (!service.answer)
return null;
const key = questionKey(message.conversation);
// This message's answer was applied already (its phase is running again): carry on from there.
const applied = pending.lastMessageId === message.id;
let next = pending;
if (!applied) {
const question = pending.questions[pending.index];
if (!question)
return null;
const parsed = parseChannelAnswer(question, message.text, texts.questions.skipWord);
if (parsed.status === 'invalid') {
await out.text(texts.questions.invalid(parsed.problem), 'question');
await askNext(texts, out, pending);
return null;
}
next = {
...pending,
index: pending.index + 1,
answers: parsed.status === 'answer'
? { ...pending.answers, [question.id]: parsed.values }
: pending.answers,
lastMessageId: message.id,
};
await store.set(key, JSON.stringify(next), questionTimeoutMs);
}
if (next.index < next.questions.length) {
await askNext(texts, out, next);
return null;
}
if (!(await settleQuestion(store, next.runId, next.toolCallId, `answer:${message.id}`))) {
// It timed out while this answer was on its way: the run went on without it, and this is a
// message like any other.
await store.delete(key);
return 'expired';
}
// The run that asked goes on: what it says next is read from its stream.
try {
await service.answer({
runId: next.runId,
toolCallId: next.toolCallId,
answers: next.answers,
answeredByRef: actor.id,
answeredVia: name,
});
}
catch (error) {
// Taken by a run of this phase that died after answering: the run has it.
if (!applied)
throw error;
}
await store.delete(key);
return next.streamRunId;
};
/** Download a message's files and stage them for the turn; refuse what cannot be attached. */
const attachMedia = async (service, texts, out, actor, message, coalesced) => {
const refs = [];
const extra = [];
const base = service.attachmentLimits?.() ?? null;
for (const media of message.media ?? []) {
const limits = options.mediaLimits ? options.mediaLimits(base, media) : base;
const refuse = (reason) => out.text(texts.mediaRefused(reason, media, limits), 'notice');
if (limits === null ||
!adapter.download ||
(!service.stageAttachment && !options.prepareMedia)) {
await refuse('disabled');
continue;
}
const declared = media.contentType?.split(';')[0]?.trim().toLowerCase();
if (declared !== undefined && !limits.allowedContentTypes.includes(declared)) {
await refuse('type');
continue;
}
if (media.sizeBytes !== undefined && media.sizeBytes > limits.maxBytes) {
await refuse('size');
continue;
}
try {
let file = await adapter.download(media, { maxBytes: limits.maxBytes });
const prepared = await options.prepareMedia?.(file, media, {
...hookContext(message, coalesced),
actor,
});
if (prepared && 'refuse' in prepared) {
await refuse(prepared.refuse);
continue;
}
if (prepared && 'text' in prepared) {
if (prepared.text.trim() !== '')
extra.push(prepared.text);
continue;
}
if (prepared && 'file' in prepared)
file = prepared.file;
if (!service.stageAttachment) {
await refuse('disabled');
continue;
}
const attachment = await service.stageAttachment(actor, {
data: file.data,
contentType: file.contentType,
filename: file.filename ?? `${media.kind}.${extensionOf(file.contentType)}`,
});
refs.push({ mediaId: attachment.mediaId });
}
catch (error) {
if (error instanceof ChannelMediaTooLargeError)
await refuse('size');
else if (error instanceof AttachmentRefusedError)
await refuse(error.status === 413 ? 'size' : error.status === 415 ? 'type' : 'failed');
else {
await refuse('failed');
options.onError?.(error, message);
}
}
}
return { refs, extra };
};
/**
* Read a run's frames until it ends (or parks on a question, or on something a text channel
* cannot settle) and deliver them. Reads the stream from its start: what was delivered before (a
* read before a question, a read a crash cut short) is skipped by its slot.
*/
const readTurn = async (service, store, actor, runId, threadId, conversation, message) => {
const texts = await textsFor(service, { actor, message });
const out = outbox(store, `${name}:out:${runId}`, conversation, { actor, message });
const parts = [];
const proposals = new Map();
/** Components rendered as files, by id (a later one replaces), sent before the text. */
const rendered = new Map();
/** Components drawn natively (genui channels), by id, sent where they came in the text. */
const natives = new Map();
const native = await genuiFor();
const asText = new Set();
let wroteText = false;
let failed = false;
let blocked = false;
let parked = false;
let timedOut = false;
let position = 0;
const deadlineAt = Date.now() + timeoutMs;
/** The reply text, its tables through {@link ChannelHandleOptions.renderTable} when there is one. */
const replyText = async (value) => {
if (!options.renderTable)
return out.text(value, 'reply');
let pending = '';
const sendPending = async () => {
if (pending.trim() !== '')
await out.text(pending.trim(), 'reply');
pending = '';
};
for (const piece of splitTables(value)) {
if (piece.kind === 'text') {
pending += `${piece.text}\n`;
continue;
}
const replies = replyList(await options.renderTable(piece.table, { actor, conversation, runId }));
if (replies.length === 0) {
pending += `${piece.source}\n`;
continue;
}
await sendPending();
for (const reply of replies)
await out.reply(reply, 'reply');
}
await sendPending();
};
const flush = async (end) => {
const batch = [...rendered.values()];
rendered.clear();
for (const { replies } of batch)
for (const reply of replies)
await out.reply(reply, 'reply');
// Text and natively drawn components, in the order the turn produced them.
const segments = parts.join('').split(NATIVE_MARK);
parts.length = 0;
for (const [index, segment] of segments.entries()) {
if (index % 2 === 0) {
if (segment.trim() !== '')
await replyText(segment.trim());
continue;
}
const messages = natives.get(segment) ?? [];
for (const [position, message] of messages.entries())
await sendNative(store, out, conversation, `${runId}:${segment}:${position}`, message);
}
natives.clear();
if (end && !wroteText && batch.length > 0) {
const after = texts.componentsOnly?.(batch.map(({ component }) => component));
if (after !== undefined && after !== '')
await out.text(after, 'reply');
}
};
const stream = service.subscribe(runId)[Symbol.asyncIterator]();
const drive = service.drive?.bind(service);
const stopDriving = executor().durable && drive !== undefined ? keepDriving(drive, runId) : undefined;
try {
for (;;) {
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve('timeout'), Math.max(0, deadlineAt - Date.now()));
});
const next = await Promise.race([stream.next(), timeout]).finally(() => clearTimeout(timer));
if (next === 'timeout') {
timedOut = true;
failed = parts.length === 0 && rendered.size === 0 && !wroteText;
await service.cancel(runId).catch(() => { });
break;
}
if (next.done)
break;
const frame = next.value;
const component = componentOf(frame, position++);
if (component !== null && native !== null) {
// A genui channel: the component as this channel draws it, where it came in the text.
const replies = options.renderComponent
? replyList(await options.renderComponent(component, {
actor,
conversation,
runId,
rendered: rendered.size,
}))
: [];
if (replies.length > 0) {
rendered.set(component.id, { component, replies });
continue;
}
const drawn = await renderChannelMessages(native.genui.catalog, {
id: component.id,
name: component.name,
props: (component.data ?? {}),
version: component.version,
...(component.componentVersions !== undefined
? { componentVersions: component.componentVersions }
: {}),
...(component.fallbackText !== undefined
? { fallbackText: component.fallbackText }
: {}),
}, native.channel);
if (drawn.length === 0)
continue;
wroteText = true;
if (!natives.has(component.id))
parts.push(`${NATIVE_MARK}${component.id}${NATIVE_MARK}`);
natives.set(component.id, drawn);
}
else if (component !== null) {
const replies = options.renderComponent
? replyList(await options.renderComponent(component, {
actor,
conversation,
runId,
rendered: rendered.size,
}))
: [];
if (replies.length > 0) {
rendered.set(component.id, { component, replies });
continue;
}
rendered.delete(component.id);
if (component.fallbackText && !asText.has(component.id)) {
// Only a component the turn negotiated as drawable comes as a frame; its text is all a
// channel that does not render it can show.
asText.add(component.id);
wroteText = true;
parts.push(`\n\n${component.fallbackText}\n\n`);
}
}
else if (frame.t === 'text') {
wroteText ||= frame.v.trim() !== '';
parts.push(frame.v);
}
else if (frame.t === 'event' && frame.event.kind === 'text') {
wroteText ||= frame.event.text.trim() !== '';
parts.push(frame.event.text);
}
else if (frame.t === 'approval') {
if (frame.target?.kind === 'proposal') {
proposals.set(frame.target.proposalId, {
id: frame.target.proposalId,
toolName: frame.toolName,
...(frame.confirmation ? { confirmation: frame.confirmation } : {}),
...(frame.expiresAt !== undefined && Number.isFinite(Date.parse(frame.expiresAt))
? { expiresAt: Date.parse(frame.expiresAt) }
: {}),
});
}
else {
// Blocking mode: the run waits for a decision this channel cannot make.
blocked = true;
break;
}
}
else if (frame.t === 'elicitation') {
const questions = frame.request.questions;
const asked = {
threadId,
runId: frame.runId,
streamRunId: runId,
toolCallId: frame.id,
...(frame.request.preamble !== undefined ? { preamble: frame.request.preamble } : {}),
questions,
index: 0,
answers: {},
};
if ((await store.get(answeredKey(frame.runId, frame.id))) !== null) {
// Answered (or skipped) before, and the run went on past it. What was sent around it
// then is sent (skipped, by its slots) the same way now, so what follows keeps its slots.
if (service.answer && questions.length > 0) {
await flush(false);
await askNext(texts, out, asked);
}
continue;
}
if (!service.answer || questions.length === 0) {
await settleQuestion(store, frame.runId, frame.id, 'skipped');
await service
.skip({
runId: frame.runId,
toolCallId: frame.id,
answeredByRef: actor.id,
answeredVia: name,
})
.catch(() => { });
continue;
}
await flush(false);
const pending = asked;
const key = questionKey(conversation);
const waiting = await store.get(key);
const same = waiting !== null && JSON.parse(waiting).toolCallId === frame.id;
if (!same)
await store.set(key, JSON.stringify(pending), questionTimeoutMs);
await askNext(texts, out, pending);
// The answers come as the next messages; nobody answering in time skips it.
await executor().later({
kind: 'timeout',
channel: name,
conversation,
runId: frame.runId,
toolCallId: frame.id,
streamRunId: runId,
threadId,
actor,
}, jobId(name, 'timeout', frame.runId, frame.id), Date.now() + questionTimeoutMs);
parked = true;
break;
}
else if (frame.t === 'error') {
failed = true;
// A failed turn shows none of what it drew before failing.
rendered.clear();
natives.clear();
}
}
}
finally {
stopDriving?.();
void stream.return?.();
}
if (parked)
return 'parked';
await flush(true);
if (failed)
await out.text(texts.failed, 'notice');
if (blocked)
await out.text(texts.blockingApproval, 'notice');
const left = [...proposals.values()];
for (const proposal of left)
await sendProposal(service, store, texts, out, conversation, proposal, left.length > 1);
return timedOut ? 'timeout' : failed ? 'failed' : blocked ? 'blocked' : 'replied';
};
const hookContext = (message, coalesced) => ({
channel: name,
conversation: message.conversation,
message,
...(coalesced !== undefined && coalesced.length > 1 ? { coalesced } : {}),
});
/** One message's gate: who sent it (`unknownSender` when nobody), and whether `beforeTurn` lets it on. */
const gate = async (service, store, message, coalesced) => {
await adapter.acknowledge?.(message).catch(() => { });
const actor = await options.actor(message);
if (actor === null) {
const out = outbox(store, `${name}:out:msg:${message.id}:sender`, message.conversation, {
actor: null,
message,
});
const replies = options.unknownSender
? replyList(await options.unknownSender(message, hookContext(message, coalesced)))
: replyList((await textsFor(service, { actor: null, message })).unknownSender);
for (const reply of replies)
await out.reply(reply, 'gate');
return { actor: null, pass: false };
}
const decided = await options.beforeTurn?.(message, actor, hookContext(message, coalesced));
if (decided === 'stop')
return { actor, pass: false };
if (decided !== undefined && decided !== 'continue') {
const out = outbox(store, `${name}:out:msg:${message.id}:gate`, message.conversation, {
actor,
message,
});
for (const reply of 'replies' in decided ? decided.replies : [decided.reply])
await out.reply(reply, 'gate');
return { actor, pass: false };
}
return { actor, pass: true };
};
/**
* Before the turn: who is talking, and whether the app lets them through. A burst goes through
* message by message — each one acknowledged, mapped and gated as if alone; the turn is made of
* those let through, as the first of them's actor.
*/
const prepare = async (received, batch) => {
const service = await serviceFor();
const store = await storeFor(service);
const messages = batch !== undefined && batch.length > 1 ? batch : [received];
const coalesced = messages.length > 1 ? messages : undefined;
let first;
let known = null;
const through = [];
for (const message of messages) {
const gated = await gate(service, store, message, coalesced);
known ??= gated.actor;
if (!gated.pass || gated.actor === null)
continue;
first ??= { actor: gated.actor, message };
through.push(message);
}
if (first === undefined)
return { stop: true, actor: known };
const threadId = (await options.thread(first.actor, first.message)) ?? null;
return {
stop: false,
actor: first.actor,
threadId,
...(coalesced !== undefined ? { messages: through } : {}),
};
};
/** A burst led by `leader` was handled (its turn started, or every message was stopped). */
const batchDoneKey = (leader) => `${name}:coalesce:done:${leader}`;
/**
* The message itself: an answer, a press, a decision, or a turn. Returns the run to read, if any.
* A turn is started once per message, however often this runs: its run id is kept under the
* message id before anything else can fail. A burst (`batch`, from `coalesce`) is one turn: each
* message's files prepared (and refused) as if alone, texts joined in order, files together.
*/
const act = async (received, actor, threadId, extra = {}) => {
const service = await serviceFor();
const store = await storeFor(service);
const batch = extra.batch !== undefined && extra.batch.length > 1 ? extra.batch : undefined;
const turnKey = `${name}:turn:${received.id}`;
const markDone = async () => {
if (extra.leader !== undefined)
await store.set(batchDoneKey(extra.leader), '1', dedupeTtlMs);
};
const started = await store.get(turnKey);
if (started !== null && started !== '') {
// This phase ran to its end before (a retry, a recovery): the same outcome.
await markDone();
return JSON.parse(started);
}
/** What this message came to, kept so a phase that runs again does not do it twice. */
const done = async (result) => {
await store.set(turnKey, JSON.stringify(result), dedupeTtlMs);
await markDone();
return result;
};
const texts = await textsFor(service, { actor, message: received });
const out = outbox(store, `${name}:out:msg:${received.id}`, received.conversation, {
actor,
message: received,
});
let message = received;
let uiButton = null;
if (batch === undefined) {
// A burst never holds a control message: those are handled alone.
const recovered = await recoverButtonId(service, store, texts, actor, threadId, received);
message = recovered === undefined ? received : { ...received, buttonId: recovered };
const ours = message.buttonId !== undefined && BUTTON_ID.test(message.buttonId);
if (!ours) {
const waiting = await store.get(questionKey(message.conversation));
const pending = waiting === null ? null : JSON.parse(waiting);
if (pending !== null && (pending.threadId === null || pending.threadId === threadId)) {
const resume = await answerQuestion(service, store, texts, out, actor, message, pending);
if (resume !== 'expired')
return done(resume === null ? null : { runId: resume, threadId: pending.threadId });
}
}
if (await pressButton(service, out, actor, threadId, message))
return done(null);
// A component's button (or list entry): the user's next turn, as a UI action — the same thing
// a sandbox's `agent.send` and an A2UI button become.
uiButton = await pressedUiButton(store, message);
}
let inbound;
if (batch !== undefined) {
const parts = [];
for (const part of batch) {
const partOut = part.id === received.id
? out
: outbox(store, `${name}:out:msg:${part.id}`, part.conversation, {
actor,
message: part,
});
const media = await attachMedia(service, texts, partOut, actor, part, batch);
parts.push({
message: part,
text: [part.text, ...media.extra].filter((piece) => piece.trim() !== '').join('\n\n'),
attachments: media.refs,
});
}
inbound = {
text: parts
.map((part) => part.text)
.filter((piece) => piece.trim() !== '')
.join('\n\n'),
attachments: parts.flatMap((part) => part.attachments),
parts,
};
}
else {
const media = uiButton === null ? await attachMedia(service, texts, out, actor, message) : null;
inbound = {
text: uiButton !== null
? uiActionText(channelButtonAction(uiButton.button, {
...(uiButton.componentId !== undefined
? { componentId: uiButton.componentId }
: {}),
...(uiButton.title !== undefined ? { title: uiButton.title } : {}),
}))
: [message.text, ...(media?.extra ?? [])]
.filter((part) => part.trim() !== '')
.join('\n\n'),
attachments: media?.refs ?? [],
};
}
if (options.transformInbound)
inbound = await options.transformInbound(inbound, {
...hookContext(message, batch),
actor,
threadId,
});
if (batch === undefined &&
inbound.attachments.length === 0 &&
(await decideByText(service, store, texts, out, actor, threadId, message, inbound.text)))
return done(null);
// A file nobody could attach, and no caption: there is nothing left to answer.
if (inbound.text === '' && inbound.attachments.length === 0)
return done(null);
if (!(await store.claim(turnKey, dedupeTtlMs))) {
// Taken by a run of this phase that died between starting the turn and recording it: never
// start a second turn for one message.
throw Object.assign(new Error(`[@adonis-agora/agent] channel ${name}: message ${message.id} may have started a turn already; not starting another`), { name: 'ChannelTurnUncertainError', status: 409 });
}
const pageContext = typeof options.pageContext === 'function'
? options.pageContext(message)
: (options.pageContext ?? { kind: name });
const channel = {
name,
conversation: message.conversation,
...(adapter.kind !== undefined ? { kind: adapter.kind } : {}),
choices: { buttons: capabilities.buttons ?? 0, lists: capabilities.lists ?? 0 },
};
// A channel genui draws for: the turn may push whatever this channel can draw (the tools are
// narrowed to it), and the components arrive whole, for the history and for native delivery.
const drawsNatively = options.uiCapabilities === undefined && (await genuiFor()) !== null;
const sendOptions = { textDecisions: false };
// When the person said it: the provider's time — a burst's first message's, when it begins.
const sentAt = (batch ?? [message]).find((part) => part.sentAt !== undefined)?.sentAt;
let sent;
try {
sent = await service.send({
actor,
message: inbound.text,
...(threadId !== null ? { threadId } : {}),
...(options.agentName !== undefined ? { agentName: options.agentName } : {}),
...(inbound.attachments.length > 0 ? { attachments: inbound.attachments } : {}),
...(sentAt !== undefined ? { sentAt } : {}),
...(drawsNatively
? {}
: { uiCapabilities: options.uiCapabilities ?? { components: [] } }),
pageContext: { ...pageContext, channel },
hostContext: {
channel: name,
conversation: message.conversation,
messageId: message.id,
...(batch !== undefined ? { messageIds: batch.map((part) => part.id) } : {}),
},
}, sendOptions);
}
catch (error) {
// Nothing started: a retry may start it.
await Promise.resolve(store.delete(turnKey)).catch(() => { });
throw error;
}
if ('proposalDecision' in sent) {
// A service that decides by text itself (one that ignores `textDecisions`).
await done(null);
await out.text(sent.text, 'notice');
const decided = sent.proposalDecision;
if ('proposal' in decided &&
decided.status === 'applied' &&
decided.proposal?.decision === 'approved')
await relayOutcome(service, actor, sent.threadId, decided.proposal.id, message);
return null;
}
// A queued message starts later under its own id: its answer is read the same way.
const runId = sent.queued === true ? (sent.runId ?? sent.messageId) : sent.runId;
// What a turn grouped (and how long it waited) is told only with `coalesce` on.
const measured = options.coalesce !== undefined;
const queueWaitMs = measured && extra.receivedAt !== undefined
? Math.max(0, Date.now() - extra.receivedAt)
: undefined;
const turn = await done({
runId,
threadId: sent.threadId,
...(measured ? { attachments: inbound.attachments.length } : {}),
...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
});
if (threadId === null)
await options.onThreadCreated?.(sent.threadId, actor, message);
await options.onTurnStarted?.({
runId,
threadId: sent.threadId,
actor,
message,
queued: sent.queued === true,
...(batch !== undefined ? { coalesced: batch } : {}),
...(queueWaitMs !== undefined ? { queueWaitMs } : {}),
});
return turn;
};
// ── coalescing: the conversation's burst log ────────────────────────────────
//
// With `coalesce` on, the webhook appends each message it takes to its conversation's log (a slot
// per message, taken with an atomic claim) before starting its job. Jobs still run one at a time
// per conversation, in order: the first one to run LEADS a burst — it waits (durably) for the
// conversation to go quiet, then claims every later message of the log that may join it (an owner
// key per message, claimed once) and makes them one turn. A message's own job, when its turn
// comes, finds it owned by a leader whose burst was handled and ends there. A message nobody
// claimed (it came after the burst closed, or is a control message) leads the next one. The log is
// only how a leader sees what arrived — a message missing from it is still handled by its own job.
const logKey = (conversation, slot) => `${name}:coalesce:${conversation}:${slot}`;
const logTailKey = (conversation) => `${name}:coalesce:${conversation}:tail`;
const logHeadKey = (conversation) => `${name}:coalesce:${conversation}:head`;
const ownerKey = (messageId) => `${name}:coalesce:owner:${messageId}`;
/** Consecutive empty slots after which the log is taken to end (a slot claimed, never written). */
const LOG_GAP = 3;
const MAX_LOG_PROBES = 500;
/** The longest log entry (a MySQL `TEXT` holds 64 KB). */
const MAX_LOG_ENTRY = 60_000;
/** Append a message to its conversation's log — a slot taken with a claim, then written. */
const appendToLog = async (store, message, at) => {
const conversation = message.conversation;
const tail = Number((await store.get(logTailKey(conversation))) ?? '0');
let slot = Number.isFinite(tail) && tail >= 0 ? tail : 0;
// The whole message, for the leader to handle it — without the provider's payload when that
// is too big for a store's text column; too big even so → left to its own job.
let value = JSON.stringify({ id: message.id, at, message });
if (value.length > MAX_LOG_ENTRY)
value = JSON.stringify({ id: message.id, at, message: { ...message, raw: null } });
if (value.length > MAX_LOG_ENTRY)
return;
for (let probe = 0; probe < MAX_LOG_PROBES; probe++, slot++) {
if (!(await store.claim(logKey(conversation, slot), dedupeTtlMs)))
continue;
await store.set(logKey(conversation, slot), value, dedupeTtlMs);
await store.set(logTailKey(conversation), String(slot + 1), dedupeTtlMs);
return;
}
// A log this full: the message is handled by its own job, alone.
};
/**
* `leader` owns `messageId` — now, or since an earlier run of this phase. A claim left without its
* owner (a phase that died between the two writes) is taken: only one job of a conversation looks
* at its log at a time.
*/
const own = async (store, messageId, leader) => {
const key = ownerKey(messageId);
if (!(await store.claim(key, dedupeTtlMs))) {
const owner = await store.get(key);
if (owner !== null && owner !== '')
return owner === leader;
}
await store.set(key, leader, dedupeTtlMs);
return true;
};
/** A message that is never grouped: a press, a text decision — and what `when` turns away. */
const groupable = (service, message) => {
if (message.buttonId !== undefined || message.buttonWithoutId === true)
return false;
if (message.text.trim() !== '') {
const vocabulary = service.actionProposalVocabulary?.() ?? DEFAULT_TEXT_ACTION_PROPOSAL_VOCABULARY;
if (parseTextActionProposalCommand(message.text, vocabulary).status === 'command')
return false;
}
return options.coalesce?.when?.(message) ?? true;
};
/**
* One look at the burst `job`'s message would lead: wait more (`wakeAt`), or claim it and the
* messages that join it. A message a leader already took ends its own job (`absorbed`) — unless
* that leader's burst was never handled (it failed before its turn), then it goes on alone.
*/
const gather = async (job) => {
const service = await serviceFor();
const store = await storeFor(service);
const self = job.message;
const alone = { batch: [self] };
if (!(await own(store, self.id, self.id))) {
const owner = await store.get(ownerKey(self.id));
if (owner !== null && (await store.get(batchDoneKey(owner))) !== null)
return { absorbed: owner };
return alone;
}
const config = options.coalesce;
if (config === undefined || !(config.quietMs > 0))
return alone;
// Every message of a question in progress is an answer, read one at a time.
if (!groupable(service, self) || (await store.get(questionKey(self.conversation))) !== null)
return alone;
const maxMessages = Math.max(1, Math.floor(config.maxMessages ?? 20));
const maxWaitMs = config.maxWaitMs ?? Math.max(30_000, config.quietMs);
const receivedAt = job.receivedAt ?? Date.now();
const conversation = self.conversation;
const slotOf = (value) => {
const slot = Number(value ?? '0');
return Number.isFinite(slot) && slot >= 0 ? Math.floor(slot) : 0;
};
const head = slotOf(await store.get(logHeadKey(conversation)));
const tail = slotOf(await store.get(logTailKey(conversation)));
const joining = [];
/** Slots of messages nobody had taken when they were read (but this one). */
const open = [];
let lastSlot;
let seenSelf = false;
let lastAt = receivedAt;
let gap = 0;
let closed = false;
for (let slot = head; slot < Math.max(head, tail) + MAX_LOG_PROBES; slot++) {
const saved = await store.get(logKey(conversation, slot));
if (saved === null) {
gap++;
if (gap >= LOG_GAP && slot >= tail)
break;
continue;
}
gap = 0;
lastSlot = slot;
const entry = JSON.parse(saved);
if (entry.id === self.id) {
seenSelf = true;
continue;
}
const owner = await store.get(ownerKey(entry.id));
if (owner !== null && owner !== '')
continue;
open.push(slot);
// What came before this message (out of order, by a hair) is left to its own job.
if (closed || (!seenSelf && entry.at < receivedAt))
continue;
if (joining.length + 1 >= maxMessages ||
entry.message.from !== self.from ||
!groupable(service, entry.message)) {
closed = true;
continue;
}
joining.push({ ...entry, slot });
lastAt = Math.max(lastAt, entry.at);
}
const now = Date.now();
const wakeAt = Math.min(lastAt + config.quietMs, receivedAt + maxWaitMs);
if (!closed && joining.length + 1 < maxMessages && now < wakeAt)
return { wakeAt };
const batch = [self];
const taken = new Set();
for (const entry of joining)
if (await own(store, entry.id, self.id)) {
batch.push(entry.message);
taken.add(entry.slot);
}
// Where the next leader starts reading: the first message nobody has taken.
const left = open.filter((slot) => !taken.has(slot));
const next = left.length > 0 ? Math.min(...left) : lastSlot !== undefined ? lastSlot + 1 : head;
if (next !== head)
await store.set(logHeadKey(conversation), String(next), dedupeTtlMs);
return { batch };
};
const retry = (fn) => withRetries(fn, options.retry);
const report = (error, message) => {
if (options.onError && message !== null)
options.onError(error, message);
else
console.error('[@adonis-agora/agent] Channel message failed', {
channel: name,
message: error instanceof Error ? error.message : String(error),
});
};
/** Tell `onTurnEnded` — never failing the job. */
const turnEnded = async (turn) => {
if (!options.onTurnEnded)
return;
try {
await options.onTurnEnded(turn);
}
catch (error) {
console.error('[@adonis-agora/agent] Channel onTurnEnded failed', {
channel: name,
message: error instanceof Error ? error.message : String(error),
});
}
};
/**
* One phase of a job, from its input alone — what a dispatched step runs in whatever process it
* lands on, and what an inline / local step calls. Each is retried (`retry`) and returns JSON.
*/
const phase = async (which, input) => {
const { job } = input;
switch (which) {
case 'timeout': {
if (job.kind !== 'timeout')
throw new Error(`phase timeout of a ${job.kind} job`);
return retry(async () => {
const service = await serviceFor();
const store = await storeFor(service);
// Answered in time: nothing to do.
if (!(await settleQuestion(store, job.runId, job.toolCallId, 'timeout')))
return false;
// Nobody answered: the agent goes on on its own assumptions.
const key = questionKey(job.conversation);
const waiting = await store.get(key);
const pending = waiting === null ? null : JSON.parse(waiting);
if (pending?.runId === job.runId && pending.toolCallId === job.toolCallId)
await store.delete(key);
await service
.skip({
runId: job.runId,
toolCallId: job.toolCallId,
answeredByRef: job.actor.id,
answeredVia: name,
})
.catch(() => { });
await executor().enqueue({
kind: 'resume',
channel: name,
conversation: job.conversation,
runId: job.streamRunId,
threadId: job.threadId,
actor: job.actor,
}, jobId(name, 'resume', job.runId, job.toolCallId));
return true;
});
}
case 'gather': {
if (job.kind !== 'message')
throw new Error(`phase gather of a ${job.kind} job`);
return retry(() => gather(job));
}
case 'prepare': {
if (job.kind !== 'message')
throw new Error(`phase prepare of a ${job.kind} job`);
return retry(async () => {
const prepared = await prepare(job.message, input.batch);
if (prepared.stop && job.coalesce === true) {
// Every message of the burst stopped at its gate: handled.
const service = await serviceFor();
await (await storeFor(service)).set(batchDoneKey(job.message.id), '1', dedupeTtlMs);
}
return prepared;
});
}
case 'act': {
if (job.kind !== 'message')
throw new Error(`phase act of a ${job.kind} job`);
const prepared = input.prepared;
const messages = prepared.messages ?? [job.message];
const [first = job.message] = messages;
return retry(() => act(first, prepared.actor, prepared.threadId, {
...(messages.length > 1 ? { batch: messages } : {}),
...(job.receivedAt !== undefined ? { receivedAt: job.receivedAt } : {}),
...(job.coalesce === true ? { leader: job.message.id } : {}),
}));
}
case 'read': {
return retry(async () => {
const service = await serviceFor();
const store = await storeFor(service);
if (job.kind === 'resume')
return readTurn(service, store, job.actor, job.runId, job.threadId, job.conversation, null);
if (job.kind !== 'message')
throw new Error(`phase read of a ${job.kind} job`);
const prepared = input.prepared;
const turn = input.turn;
const [first = job.message] = prepared.messages ?? [job.message];
return readTurn(service, store, prepared.actor, turn.runId, turn.threadId, job.message.conversation, first);
});
}
}
};
/** What a `read` checkpointed: its outcome (a run recorded before it said so: `replied`). */
const outcomeOf = (value) => typeof value === 'string' ? value : 'replied';
/** One job, phase by phase — each phase a checkpoint (or a dispatched step) under a durable engine. */
const run = async (job, io) => {
const message = job.kind === 'message' ? job.message : null;
/** What `onTurnEnded` is told — a question's timeout is not a turn (the resume it starts is). */
const ended = {
channel: name,
conversation: job.conversation,
actor: job.kind === 'resume' ? job.actor : null,
message,
runId: job.kind === 'resume' ? job.runId : null,
threadId: job.kind === 'resume' ? job.threadId : null,
outcome: 'replied',
};
/** Not told: a burst's wait (the job goes on later), or a message another job's turn took. */
let quiet = false;
let waiting = false;
try {
if (job.kind === 'timeout') {
await io.phase('timeout', { job });
return;
}
if (job.kind === 'resume') {
ended.outcome = outcomeOf(await io.phase('read', { job }));
return;
}
let batch;
if (job.coalesce === true) {
for (let round = 0;; round++) {
const gathered = await io.phase('gather', { job, round });
if ('wakeAt' in gathered) {
waiting = true;
await io.sleepUntil(gathered.wakeAt);
waiting = false;
continue;
}
if ('absorbed' in gathered) {
quiet = true;
return;
}
if (gathered.batch.length > 1)
batch = gathered.batch;
break;
}
}
const prepared = await io.phase('prepare', {
job,
...(batch !== undefined ? { batch } : {}),
});
ended.actor = prepared.actor ?? null;
if (prepared.stop) {
ended.outcome = 'stopped';
return;
}
ended.threadId = prepared.threadId;
const through = prepared.messages;
if (through !== undefined && through.length > 0)
ended.message = through[0] ?? message;
if (through !== undefined && through.length > 1)
ended.coalesced = through;
const turn = await io.phase('act', { job, prepared });
if (turn === null) {
ended.outcome = 'handled';
return;
}
ended.runId = turn.runId;
ended.threadId = turn.threadId;
if (turn.attachments !== undefined)
ended.attachments = turn.attachments;
if (turn.queueWaitMs !== undefined)
ended.queueWaitMs = turn.queueWaitMs;
ended.outcome = outcomeOf(await io.phase('read', { job, prepared, turn }));
}
catch (error) {
if (waiting && isControlFlowSignal(error)) {
// The burst's wait suspended the run: it goes on (here or elsewhere) once it is due.
quiet = true;
throw error;
}
if (error?.name === 'WorkflowSuspended') {
ended.outcome = 'interrupted';
throw error;
}
ended.outcome = 'failed';
ended.error = error;
report(error, ended.message);
}
finally {
if (job.kind !== 'timeout' && !quiet)
await turnEnded(ended);
}
};
/** Each dispatched phase's liveness window: what it may take, with a margin. */
const stepTimeoutMs = (which) => {
const margin = 2 * 60_000;
switch (which) {
case 'read':
// The turn, the outcome of a proposal it left, and the sends.
return timeoutMs + Math.max(0, outcomeTimeoutMs) + margin;
case 'act':
// Media downloads, `prepareMedia` (a transcription), the turn's start — retried.
return 15 * 60_000;
case 'prepare':
return 10 * 60_000;
default:
return margin;
}
};
const runner = {
run,
phase,
stepTimeoutMs,
...(options.durableSteps === false ? { dispatched: false } : {}),
};
let current;
/** Durable when an engine is there (given, or the agent's), else in this process. */
const executor = () => {
if (current)
return current;
const engine = options.durable === false
? undefined
: typeof options.durable === 'object'
? options.durable
: registeredAgentEngine();
if (engine === undefined && options.durable === true)
console.warn(`[@adonis-agora/agent] channel ${name}: \`durable: true\` but no durable engine is wired (\`durable: true\` in config/agent.ts) — messages are handled in this process`);
if (engine !== undefined) {
registerChannelWorkflows(engine, runnerFor, stepServerFor(engine));
current = durableExecutor(engine);
return current;
}
current = inlineExecutor((job) => run(job, inlineIo(runner)));
return current;
};
/** Where `engine`'s channel phases are dispatched: the option, else the agent engine's transport. */
function stepServerFor(engine) {
if (options.durableSteps === false)
return undefined;
if (options.durableSteps !== undefined)
return options.durableSteps;
return engine === registeredAgentEngine()
? registeredAgentStepServer()
: undefined;
}
registry.set(name, {
adapter,
runner,
relay: (proposal, conversation, service) => relay(proposal, conversation, service),
follow: (conversation, run) => executor().enqueue({
kind: 'resume',
channel: name,
conversation,
runId: run.runId,
threadId: run.threadId,
actor: run.actor,
}, jobId(name, 'follow', run.runId)),
});
// Registered on the engine as soon as there is one: a restarted process resumes the jobs a crash
// interrupted only for workflows it knows.
if (typeof options.durable === 'object')
registerChannelWorkflows(options.durable, runnerFor, stepServerFor(options.durable));
else if (options.durable !== false)
followAgentEngine();
const notify = async (event) => {
try {
await options.onWebhook?.({ channel: name, ...event });
}
catch {
// Observation never changes the answer.
}
};
const handleRequest = async (request, logger) => {
const empty = { accepted: 0, duplicates: 0, messages: [] };
const challenge = adapter.challenge?.(request) ?? null;
if (challenge !== null) {
await notify({ status: 'challenge', ...empty });
return {
status: challenge.status,
body: challenge.body,
contentType: challenge.contentType ?? 'text/plain',
};
}
if (request.method !== 'POST') {
await notify({ status: 'method_not_allowed', ...empty });
return { status: 405, body: { error: 'method_not_allowed' } };
}
if (!(await adapter.verify(request))) {
await notify({ status: 'unauthorized', ...empty });
return { status: 401, body: { error: 'unauthorized' } };
}
const parsed = adapter.parse(request.body);
const messages = parsed === null ? [] : Array.isArray(parsed) ? parsed : [parsed];
if (messages.length === 0) {
const ignored = adapter.ignored?.(request.body) ?? null;
const event = ignored?.event ??
(typeof request.body === 'object' && request.body !== null
? request.body.event
: undefined);
const reason = ignored?.reason ?? 'no message';
// Logged with the event and the reason — never the content — so a dropped message can be
// diagnosed.
if (logger) {
const log = ignored?.unexpected ? logger.warn : logger.debug;
log?.call(logger, { channel: name, ...(typeof event === 'string' ? { event } : {}), reason }, 'channels: webhook ignored');
}
await notify({
status: 'ignored',
reason,
...(typeof event === 'string' ? { event } : {}),
...empty,
});
return { status: 200, body: { ok: true } };
}
const fresh = [];
try {
const store = await storeFor(await serviceFor());
// Taken before the 200: a store or an engine that is down answers 500, and the provider
// retries.
const coalesce = options.coalesce !== undefined && options.coalesce.quietMs > 0;
for (const message of messages) {
const key = `${name}:${message.id}`;
if (!(await store.claim(key, dedupeTtlMs)))
continue;
try {
const receivedAt = Date.now();
// A burst's leader finds it in the conversation's log; its own job runs either way.
if (coalesce)
await appendToLog(store, message, receivedAt);
await executor().enqueue({
kind: 'message',
channel: name,
conversation: message.conversation,
message,
receivedAt,
...(coalesce ? { coalesce: true } : {}),
}, jobId(name, 'message', message.id));
}
catch (error) {
await Promise.resolve(store.delete(key)).catch(() => { });
throw error;
}
fresh.push(message);
}
}
catch (error) {
await notify({
status: 'failed',
reason: error instanceof Error ? error.message : String(error),
accepted: fresh.length,
duplicates: 0,
messages: fresh,
});
return { status: 500, body: { error: 'unavailable' } };
}
await notify({
status: fresh.length > 0 ? 'accepted' : 'duplicate',
accepted: fresh.length,
duplicates: messages.length - fresh.length,
messages: fresh,
});
return { status: 200, body: { ok: true } };
};
const handler = async (ctx) => {
const answer = await handleRequest(requestOf(ctx), ctx.logger);
if (answer.contentType !== undefined)
ctx.response
.status(answer.status)
.header('content-type', answer.contentType)
.send(answer.body);
else
ctx.response.status(answer.status).send(answer.body);
};
return Object.assign(handler, {
drain: () => executor().drain(),
handleRequest: (request) => handleRequest(request),
});
}
/**
* Relay an executed proposal's outcome to the conversation it was proposed in — once: the handler's
* own wait and this share a claim in the channel's store. `false` when it was not relayed (no
* channel recorded, the channel is not registered in this process, not executed, already relayed).
*/
async function deliverOutcome(proposal, service) {
const address = record(proposal.executionContext?.pageContext?.channel);
const channelName = typeof address?.name === 'string' ? address.name : undefined;
const conversation = typeof address?.conversation === 'string' ? address.conversation : undefined;
const registration = channelName === undefined ? undefined : registry.get(channelName);
if (!registration || conversation === undefined)
return false;
return registration.relay(proposal, conversation, service);
}
/**
* `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 async function relayChannelOutcome(proposal) {
await deliverOutcome(proposal);
}
export const channels = {
/** See {@link handleChannel}. */
handle: handleChannel,
/** See {@link relayChannelOutcome}. */
onSettled: relayChannelOutcome,
/** Relay one proposal's outcome now; `true` when it was sent. */
deliverOutcome: (proposal, service) => deliverOutcome(proposal, service),
/** The adapter registered under `name` (by `channels.handle`), if any. */
adapter: (name) => registry.get(name)?.adapter,
/**
* 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: async (address, run) => {
const at = record(address);
const channelName = typeof at?.name === 'string' ? at.name : undefined;
const conversation = typeof at?.conversation === 'string' ? at.conversation : undefined;
const registration = channelName === undefined ? undefined : registry.get(channelName);
if (!registration || conversation === undefined)
return false;
await registration.follow(conversation, run);
return true;
},
};
//# sourceMappingURL=handler.js.map