UNPKG

@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
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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;'); } /** 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