@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
538 lines • 25.7 kB
JavaScript
import { defineCatalog, } from './catalog.js';
import { defineSandbox, sandboxPolicyOf } from './sandbox.js';
import { componentToText } from './text.js';
import { GENUI_TREE_COMPONENT } from './tree.js';
/** The channel a turn runs on when nothing says otherwise: a browser (AG-UI, the HTTP chat, A2UI). */
export const WEB_CHANNEL = 'web';
/** The `channels` entry that serves every channel without one of its own. */
export const DEFAULT_CHANNEL = 'default';
/** Channels a person reads in a messaging app or a mailbox: the server draws for them. */
export const MESSAGING_CHANNELS = Object.freeze([
'whatsapp',
'telegram',
'email',
]);
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* THE rule for which channel a turn runs on, read from its page context:
*
* - `pageContext.channel` is a string → that channel (an explicit override: `send({ channel })`, a
* mobile client posting `pageContext: { channel: 'mobile' }`);
* - `pageContext.channel` is the address a text-channel adapter stamps (`{ name, conversation,
* kind? }`) → its `kind` (`whatsapp`, `telegram`), else its `name`;
* - nothing → `web`: what AG-UI, the HTTP chat and A2UI serve (they stamp nothing, so a tool that
* skips skeletons when `pageContext.channel` is set keeps working).
*/
export function turnChannel(pageContext) {
const value = pageContext?.channel;
if (typeof value === 'string' && value.length > 0)
return value;
if (isRecord(value)) {
if (typeof value.kind === 'string' && value.kind.length > 0)
return value.kind;
if (typeof value.name === 'string' && value.name.length > 0)
return value.name;
}
return WEB_CHANNEL;
}
/** WhatsApp, Telegram and email are drawn by the server; every other channel by a client. */
export function isMessagingChannel(channel) {
return MESSAGING_CHANNELS.includes(channel);
}
/** One channel's options, its own entry over `default` over the top level. */
export function resolveGenuiChannel(base, channels, channel) {
const name = channel ?? WEB_CHANNEL;
const messaging = isMessagingChannel(name);
const entry = channels === undefined ? undefined : (channels[name] ?? channels.default);
if (entry === undefined) {
return {
name,
messaging,
configured: false,
mode: base.mode ?? 'tree',
...(base.streaming !== undefined ? { streaming: base.streaming } : {}),
...(base.sandbox !== undefined ? { sandbox: base.sandbox } : {}),
render: name === 'email' ? 'html' : 'native',
};
}
const sandbox = messaging ? false : (entry.sandbox ?? base.sandbox);
const streaming = entry.streaming ?? base.streaming;
return {
name,
messaging,
configured: true,
mode: entry.mode ?? (messaging ? 'per-component' : (base.mode ?? 'tree')),
...(streaming !== undefined ? { streaming } : {}),
...(sandbox !== undefined ? { sandbox } : {}),
render: entry.render ?? (name === 'email' ? 'html' : 'native'),
...(entry.chartImages !== undefined ? { chartImages: entry.chartImages } : {}),
...(entry.instructions !== undefined ? { instructions: entry.instructions } : {}),
};
}
/** Throws on a `channels` entry that names an unknown mode or render. */
export function assertGenuiChannels(channels) {
if (channels === undefined)
return;
if (!isRecord(channels))
throw new TypeError('genui: channels must be an object');
for (const [name, entry] of Object.entries(channels)) {
if (entry === undefined)
continue;
if (!isRecord(entry))
throw new TypeError(`genui: channels.${name} must be an object`);
if (entry.mode !== undefined && !['tree', 'per-component', 'text'].includes(String(entry.mode)))
throw new TypeError(`genui: channels.${name}.mode must be 'tree', 'per-component' or 'text'`);
if (entry.render !== undefined && !['native', 'text', 'html'].includes(String(entry.render)))
throw new TypeError(`genui: channels.${name}.render must be 'native', 'text' or 'html'`);
if (entry.streaming !== undefined &&
entry.streaming !== 'partial' &&
entry.streaming !== 'complete')
throw new TypeError(`genui: channels.${name}.streaming must be 'partial' or 'complete'`);
}
}
function conversionEntry(definition, channel) {
const channels = definition?.channels;
return channels === undefined || !Object.hasOwn(channels, channel)
? undefined
: channels[channel];
}
/** The component's conversion function for `channel` (`false`: opted out; `undefined`: none). */
function conversionOf(definition, channel) {
const entry = conversionEntry(definition, channel);
if (entry === false || entry === undefined || typeof entry === 'function')
return entry;
return typeof entry === 'object' && typeof entry.convert === 'function'
? entry.convert
: undefined;
}
/** The conversion's own words for the model (`{ convert, describe }`), if it has them. */
function conversionDescription(definition, channel) {
const entry = conversionEntry(definition, channel);
if (typeof entry !== 'object' || entry === null || definition === undefined)
return undefined;
const words = typeof entry.describe === 'function'
? entry.describe({ channel, component: definition.name, version: definition.version ?? 1 })
: entry.describe;
return typeof words === 'string' && words.trim() !== '' ? words.trim() : undefined;
}
/** A sandbox component (it carries a sandbox policy). */
function isSandbox(definition) {
return sandboxPolicyOf(definition) !== undefined;
}
/**
* Whether `definition` can be drawn on `channel`. On a web channel every component can (the client's
* `uiCapabilities` narrow it); on a messaging channel a component can when it has a conversion for
* it, a chart renderer draws it, or it has a text summary (`fallbackText`) — a component without one
* would arrive as a JSON dump, so it is not offered. A sandbox never can, and `channels: { x: false }`
* opts a component out.
*/
export function canRenderOnChannel(definition, channel) {
if (definition.internal === true)
return false;
if (isSandbox(definition))
return !channel.messaging && channel.sandbox !== false;
if (!channel.messaging)
return conversionOf(definition, channel.name) !== false;
const conversion = conversionOf(definition, channel.name);
if (conversion === false)
return false;
if (definition.fallbackText !== undefined)
return true;
if (channel.render === 'text')
return false;
if (typeof conversion === 'function')
return true;
return channel.chartImages?.components.includes(definition.name) === true;
}
/**
* The catalog as `channel` may draw it: only what it can render, and its own sandbox (or none). With
* no `channels` configured the catalog is returned as it is.
*/
export function channelCatalog(catalog, channel) {
if (!channel.configured)
return catalog;
if (channel.mode === 'text')
return defineCatalog([], { jsonSchemaValidator: catalog.validator });
const existing = catalog.components.find(isSandbox);
const kept = catalog.components.filter((definition) => !isSandbox(definition) && canRenderOnChannel(definition, channel));
const sandbox = channel.messaging || channel.sandbox === false
? undefined
: channel.sandbox === undefined || channel.sandbox === true
? (existing ?? (channel.sandbox === true ? defineSandbox() : undefined))
: defineSandbox({
...(existing !== undefined ? { name: existing.name } : {}),
...channel.sandbox,
});
return defineCatalog(sandbox === undefined ? kept : [...kept, sandbox], {
jsonSchemaValidator: catalog.validator,
});
}
const CHANNEL_LABELS = {
whatsapp: 'WhatsApp',
telegram: 'Telegram',
email: 'email',
};
/** What a UI tool's description says about the channel the conversation is on. */
export function channelInstructions(channel) {
if (!channel.configured)
return undefined;
const lines = [];
if (channel.messaging) {
const label = CHANNEL_LABELS[channel.name] ?? channel.name;
lines.push(channel.render === 'html'
? `This conversation is read by ${label}: components arrive as formatted messages.`
: channel.render === 'text'
? `This conversation is on ${label}: components arrive as their text summary.`
: `This conversation is on ${label}: components arrive as ${label} messages (text, reply buttons, lists, images) — no interactive views, no custom layouts.`);
}
if (channel.instructions !== undefined)
lines.push(channel.instructions);
return lines.length > 0 ? lines.join(' ') : undefined;
}
function titleOf(props) {
return typeof props.title === 'string' && props.title.length > 0 ? props.title : undefined;
}
function escapeHtml(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
/** Plain text as minimal HTML: paragraphs, line breaks, `*bold*`. */
export function textToHtml(text) {
return text
.split(/\n{2,}/)
.filter((block) => block.trim() !== '')
.map((block) => `<p>${escapeHtml(block)
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*\n]+)\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')}</p>`)
.join('\n');
}
async function nodeMessages(catalog, name, props, channel, context) {
if (channel.render === 'text')
return undefined;
const definition = catalog.get(name);
const conversion = conversionOf(definition, channel.name);
if (typeof conversion === 'function') {
try {
const converted = await conversion(props, {
channel: channel.name,
component: name,
...context,
});
if (converted !== null && converted !== undefined)
return Array.isArray(converted) ? converted : [converted];
}
catch {
/* a failing conversion falls back to the text summary */
}
}
if (channel.chartImages?.components.includes(name) === true) {
try {
const image = await channel.chartImages.render(name, props);
if (image !== null) {
const title = titleOf(props);
return [{ image, ...(title !== undefined ? { text: `*${title}*` } : {}) }];
}
}
catch {
/* no image: the text summary */
}
}
return undefined;
}
/**
* What a pushed component (a tree included) is on a channel the server draws for: one or more
* messages — its conversions, chart images, and the text summary of everything else, in order.
* Consecutive text is joined. Never throws: a conversion that fails sends the summary.
*/
export async function renderChannelMessages(catalog, component, channel) {
const out = [];
const pushText = (text, from) => {
// (`from` names where the text came from: the first component of a run of text.)
if (text.trim() === '')
return;
const last = out.at(-1);
if (last !== undefined &&
last.buttons === undefined &&
last.list === undefined &&
last.image === undefined &&
last.html === undefined &&
last.text !== undefined) {
last.text = `${last.text}\n\n${text}`;
return;
}
out.push({
text,
component: from.name,
...(from.id !== undefined ? { componentId: from.id } : {}),
...(from.title !== undefined ? { title: from.title } : {}),
});
};
let unknownVersion = false;
const visit = async (name, props, id, children, depth) => {
if (depth > 32)
return;
const title = titleOf(props);
const from = {
name,
...(id !== undefined ? { id } : {}),
...(title !== undefined ? { title } : {}),
};
// The props' version: the frame's own, or — for a tree's node — the tree's entry for it.
const tree = component.name === GENUI_TREE_COMPONENT;
const version = tree ? (component.componentVersions?.[name] ?? 1) : (component.version ?? 1);
// Props for a schema version newer than this catalog's (a newer client, a frame from a later
// deploy): never converted, drawn or summarised as if they were the known one — the frame's own
// text stands for them (once, for the frame). An older version still converts (it is told it).
if (version > (catalog.get(name)?.version ?? 1)) {
if (!unknownVersion) {
unknownVersion = true;
pushText(component.fallbackText ?? '', from);
}
return;
}
const native = await nodeMessages(catalog, name, props, channel, {
version,
...(id !== undefined ? { id } : {}),
frame: component,
});
if (native !== undefined) {
for (const message of native) {
if (message.image === undefined &&
message.buttons === undefined &&
message.list === undefined &&
message.html === undefined &&
typeof message.text === 'string') {
pushText(message.text, from);
continue;
}
out.push({
...message,
component: name,
...(id !== undefined ? { componentId: id } : {}),
...(title !== undefined ? { title } : {}),
});
}
}
else {
const definition = catalog.get(name);
const own = definition?.children === true && definition.fallbackText === undefined
? ''
: componentToText(catalog, name, props);
pushText(own, from);
}
for (const child of children ?? [])
await visit(child.type, child.props, id, child.children, depth + 1);
};
if (component.name === GENUI_TREE_COMPONENT) {
const root = component.props.root;
if (root !== undefined) {
await visit(root.type, root.props, component.id, root.children, 0);
}
}
else {
await visit(component.name, component.props, component.id, undefined, 0);
}
if (channel.render === 'html') {
for (const message of out) {
if (message.html === undefined && message.text !== undefined)
message.html = textToHtml(message.text);
}
}
return out;
}
/** The UI action a pressed button (or picked list entry) stands for — the user's next turn. */
export function channelButtonAction(button, from) {
return {
source: 'component',
name: button.action !== undefined && /^[A-Za-z0-9_.:-]{1,64}$/.test(button.action)
? button.action
: 'select',
context: isRecord(button.value) ? button.value : {},
text: button.label,
...(from.componentId !== undefined ? { componentId: from.componentId } : {}),
...(from.title !== undefined ? { title: from.title } : {}),
timestamp: new Date().toISOString(),
};
}
// ── What the model is told on a channel ───────────────────────────────────────────────────────
/** What a messaging channel can show at all, in the words the model reads. */
const CHANNEL_FORMS = {
whatsapp: 'text, up to 3 reply buttons, a pick-list (a list the user opens and picks one entry from) and images',
telegram: 'text, buttons under a message and images',
email: 'formatted text (an email): nothing in it is interactive',
};
function channelLabel(channel) {
return CHANNEL_LABELS[channel.name] ?? channel.name;
}
/**
* The system-prompt block for a turn on a messaging channel (WhatsApp, Telegram, email): that the
* user is not on a web page, what the channel can show, and — component by component — how each one
* reaches the user there, from its channel conversion (its `describe`, else what a conversion can
* send), a chart image, or its text summary. Tool and component descriptions are written for the web
* ("a table with a Refund button per row"); this is what keeps the model from describing that view
* on WhatsApp. `undefined` on a web-like channel, or without `channels`.
*/
export function channelPrompt(catalog, channel) {
if (!channel.configured || !channel.messaging)
return undefined;
const label = channelLabel(channel);
const forms = CHANNEL_FORMS[channel.name] ?? 'text messages';
const own = [];
const asText = [];
const layouts = [];
const allText = channel.mode === 'text' || channel.render === 'text';
for (const definition of catalog.components) {
if (isSandbox(definition))
continue;
const conversion = conversionOf(definition, channel.name);
if (conversion === false)
continue;
const named = definition.title === definition.name
? definition.name
: `${definition.title} (${definition.name})`;
if (definition.children === true) {
layouts.push(named);
continue;
}
if (allText) {
asText.push(named);
continue;
}
const described = conversionDescription(definition, channel.name);
if (described !== undefined) {
own.push(`- ${named}: ${described}`);
}
else if (typeof conversion === 'function') {
own.push(channel.render === 'html'
? `- ${named}: a formatted section of the email — not the web view.`
: `- ${named}: a ${label} message of its own (text, maybe with reply buttons, a pick-list or an image) — not the web view; the tool result's \`delivered\` says exactly what was sent.`);
}
else if (channel.chartImages?.components.includes(definition.name) === true) {
own.push(`- ${named}: a picture of the chart (an image, its title as the caption) — nothing in it can be hovered, clicked or filtered.`);
}
else {
asText.push(named);
}
}
const lines = [
`## Channel: ${label}`,
`This conversation happens in ${label}, not on a web page. What tools and components show reaches the user as ${label} messages, and ${label} can only show ${forms}. There are no tables, cards, dashboards, charts to interact with, interactive views, or buttons inside a component.`,
'Tool and component descriptions may describe the web view (a table, a button per row, a chart to hover). The user does not get that view here. How each component reaches the user here:',
...own,
...(asText.length > 0
? [
`- As plain text (its summary), with no table, no buttons and nothing to click: ${asText.join(', ')}.`,
]
: []),
...(layouts.length > 0
? [`- Layouts, sent as their contents one after another: ${layouts.join(', ')}.`]
: []),
`When a tool shows something, its result's \`delivered\` says exactly what the user received on ${label}. Describe only that: never mention a table, a button, a link or a view the user did not get, and never point "above"/"below" at one. To act on something, the user picks it from a list or buttons that were sent, or replies with what they want.`,
];
if (channel.instructions !== undefined)
lines.push(channel.instructions);
return lines.join('\n');
}
function excerpt(text, max = 120) {
const flat = text.replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
}
function quoted(labels, max = 10) {
const shown = labels.slice(0, max).map((label) => `"${excerpt(label, 40)}"`);
return labels.length > max
? `${shown.join(', ')} and ${labels.length - max} more`
: shown.join(', ');
}
/** What a channel's own adapters show, for a turn that did not say (WhatsApp Cloud, Telegram). */
const DEFAULT_CHOICE_LIMITS = {
whatsapp: { buttons: 3, lists: 10 },
telegram: { buttons: 8, lists: 30 },
};
/**
* What a channel actually delivered, in words for the model: each message (text, a pick-list and its
* entries, reply buttons, numbered choices, an image, an email section), and what the user did NOT
* get. This is what a tool's result carries as `delivered` on a messaging channel, so the model talks
* about the list the user can pick from — not the table with Refund buttons it would have on the web.
*
* Choices follow the channel handler's rule, within `limits` (default: the channel's own adapter's):
* reply buttons when there is no list (or no lists) and they fit, else a list of at most `lists`
* rows, else numbered text the user answers by replying.
*/
export function describeChannelDelivery(messages, channel, limits) {
const label = channelLabel(channel);
if (messages.length === 0)
return `Nothing was shown on ${label} for this.`;
const { buttons: maxButtons, lists: maxRows } = limits ??
DEFAULT_CHOICE_LIMITS[channel.name] ?? { buttons: 0, lists: 0 };
let interactive = false;
let numbered = false;
const parts = [];
for (const message of messages) {
const text = message.text !== undefined && message.text.trim() !== '' ? message.text : undefined;
if (message.image !== undefined)
parts.push(`an image${text !== undefined ? ` captioned "${excerpt(text)}"` : ''}`);
const choices = message.list?.items ?? message.buttons ?? [];
// With an image, the text was its caption: the choices follow it on their own.
const body = message.image === undefined ? text : undefined;
const lead = body !== undefined ? `a message "${excerpt(body)}"` : 'a message';
if (choices.length === 0) {
if (message.image !== undefined)
continue;
parts.push(message.html !== undefined && channel.render === 'html'
? `a formatted email section${text !== undefined ? ` ("${excerpt(text)}")` : ''}`
: `a text message "${excerpt(text ?? '')}"`);
continue;
}
const labels = choices.map((choice) => choice.label);
const asButtons = message.list === undefined || maxRows === 0;
if (asButtons && choices.length <= maxButtons) {
interactive = true;
parts.push(`${lead} with ${choices.length} reply ${choices.length === 1 ? 'button' : 'buttons'}: ${quoted(labels)} — pressing one comes back as the user's next message`);
}
else if (maxRows > 0) {
interactive = true;
const rows = labels.slice(0, maxRows);
const cut = labels.length > rows.length ? ` (only the first ${rows.length} of ${labels.length})` : '';
const button = message.list?.button ?? 'Options';
parts.push(`${lead} with a pick-list${message.list?.title !== undefined ? ` "${excerpt(message.list.title, 40)}"` : ''} (opened with "${excerpt(button, 30)}") of ${rows.length} ${rows.length === 1 ? 'entry' : 'entries'}${cut}: ${quoted(rows)} — picking one comes back as the user's next message`);
}
else {
numbered = true;
parts.push(`${lead} with ${labels.length} numbered ${labels.length === 1 ? 'choice' : 'choices'} as text: ${quoted(labels)} — the user answers by replying with one`);
}
}
if (parts.length === 0)
return `Nothing was shown on ${label} for this.`;
const listed = parts.length === 1 ? parts[0] : parts.map((part, index) => `${index + 1}) ${part}`).join('; ');
return [
`Shown on ${label} as ${listed}.`,
interactive
? 'That is all the user got: no table, no web view, and no buttons other than the ones listed.'
: numbered
? 'That is all the user got: no table, no web view, no buttons and nothing to click — they answer by replying with a message.'
: 'That is all the user got: no table, no web view, no buttons and nothing to click — they can only reply with a message.',
].join(' ');
}
/**
* {@link describeChannelDelivery} of the components a tool pushed (last props per id, previews left
* out), rendered the way the channel draws them. `undefined` on a channel the server does not draw
* for, or without `channels`.
*/
export async function describeChannelComponents(catalog, components, channel, limits) {
if (!channel.configured || !channel.messaging)
return undefined;
const messages = [];
for (const component of components) {
if (component.partial === true)
continue;
messages.push(...(await renderChannelMessages(catalog, component, channel)));
}
return components.length === 0 ? undefined : describeChannelDelivery(messages, channel, limits);
}
//# sourceMappingURL=channels.js.map