UNPKG

@adonis-agora/agent

Version:

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

657 lines • 28.4 kB
import { readUiAction, readUiActionText, } from '../genui/actions.js'; import { toJsonSchema } from '../genui/schema.js'; import { componentToText } from '../genui/text.js'; import { GENUI_TREE_COMPONENT } from '../genui/tree.js'; import { resolveToolConfirmation } from '../tool-presentation.js'; /** The A2UI protocol version every message is stamped with. */ export const A2UI_VERSION = 'v0.9'; /** The catalog id of A2UI v0.9's basic catalog (what `@a2ui/react`'s `basicCatalog.id` is). */ export const A2UI_BASIC_CATALOG_ID = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; /** * The id the AG-UI A2UI binding knows the basic catalog by: `@ag-ui/a2ui-middleware`, * `@ag-ui/a2ui-toolkit` and CopilotKit's A2UI renderer (1.77, on `@a2ui/web_core` 0.10) register it * under this earlier v0.9 id, not {@link A2UI_BASIC_CATALOG_ID}. The AG-UI transport's default. */ export const A2UI_LEGACY_BASIC_CATALOG_ID = 'https://a2ui.org/specification/v0_9/basic_catalog.json'; /** Every id the basic catalog goes by: the current one first. */ export const A2UI_BASIC_CATALOG_IDS = Object.freeze([ A2UI_BASIC_CATALOG_ID, A2UI_LEGACY_BASIC_CATALOG_ID, ]); /** Whether a catalog id names A2UI v0.9's basic catalog (under either of its ids). */ export function isA2uiBasicCatalogId(id) { return typeof id === 'string' && A2UI_BASIC_CATALOG_IDS.includes(id); } /** The components of A2UI v0.9's basic catalog. */ export const A2UI_BASIC_COMPONENTS = [ 'Text', 'Image', 'Icon', 'Video', 'AudioPlayer', 'Row', 'Column', 'List', 'Card', 'Tabs', 'Modal', 'Divider', 'Button', 'CheckBox', 'TextField', 'DateTimeInput', 'ChoicePicker', 'Slider', ]; /** The AG-UI activity type A2UI surfaces travel under (AG-UI's A2UI middleware, CopilotKit). */ export const A2UI_ACTIVITY_TYPE = 'a2ui-surface'; /** The key of the A2UI messages in an `a2ui-surface` activity's content. */ export const A2UI_OPERATIONS_KEY = 'a2ui_operations'; /** Action names the transports answer themselves: deciding a parked approval. */ export const A2UI_APPROVE_ACTION = 'agora.approve'; export const A2UI_REJECT_ACTION = 'agora.reject'; function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } const str = (value, fallback = '') => typeof value === 'string' ? value : typeof value === 'number' ? String(value) : fallback; function text(id, value, variant) { return { id, component: 'Text', text: value, ...(variant !== undefined ? { variant } : {}) }; } /** A column of `[heading?, ...children]` — what most builtins come down to. */ function titled(ctx, title, children, extra = []) { const heading = typeof title === 'string' && title.length > 0; const titleId = ctx.derive('title'); return [ { id: ctx.id, component: 'Column', children: heading ? [titleId, ...children] : [...children], }, ...(heading ? [text(titleId, title, 'h4')] : []), ...extra, ]; } function cards(ctx, items, lines) { const list = Array.isArray(items) ? items.filter(isRecord) : []; const out = []; const ids = []; list.forEach((item, index) => { const card = ctx.derive(`item${index}`); const column = ctx.derive(`item${index}col`); const parts = lines(item).filter(([value]) => value.length > 0); ids.push(card); out.push({ id: card, component: 'Card', child: column }); out.push({ id: column, component: 'Column', children: parts.map((_, line) => ctx.derive(`item${index}l${line}`)), }); parts.forEach(([value, variant], line) => { out.push(text(ctx.derive(`item${index}l${line}`), value, variant)); }); }); return [{ id: ctx.id, component: 'Row', children: ids }, ...out]; } function table(ctx, title, columns, rows) { const out = []; const header = ctx.derive('header'); const rowIds = rows.map((_, index) => ctx.derive(`row${index}`)); out.push({ id: header, component: 'Row', children: columns.map((_, index) => ctx.derive(`h${index}`)), }); columns.forEach((column, index) => { out.push({ ...text(ctx.derive(`h${index}`), column.label, 'caption'), weight: 1 }); }); rows.forEach((row, r) => { out.push({ id: rowIds[r], component: 'Row', children: columns.map((_, c) => ctx.derive(`r${r}c${c}`)), }); columns.forEach((column, c) => { out.push({ ...text(ctx.derive(`r${r}c${c}`), str(row[column.key])), weight: 1 }); }); }); return titled(ctx, title, [header, ...rowIds], out); } function columnsOf(value) { return (Array.isArray(value) ? value : []) .filter(isRecord) .filter((column) => typeof column.key === 'string') .map((column) => ({ key: column.key, label: str(column.label, column.key), })); } function rowsOf(value) { return (Array.isArray(value) ? value : []).filter(isRecord); } /** The library builtins, mapped onto A2UI's basic catalog. */ export const A2UI_BUILTIN_MAPPERS = Object.freeze({ Stack: (props, ctx) => [ { id: ctx.id, component: props.direction === 'row' ? 'Row' : 'Column', children: [...ctx.children], }, ], Card: (props, ctx) => { const inner = ctx.derive('body'); const subtitle = ctx.derive('subtitle'); const hasSubtitle = typeof props.subtitle === 'string' && props.subtitle.length > 0; return [ { id: ctx.id, component: 'Card', child: inner }, ...titled({ ...ctx, id: inner }, props.title, hasSubtitle ? [subtitle, ...ctx.children] : ctx.children, hasSubtitle ? [text(subtitle, props.subtitle, 'caption')] : []), ]; }, Heading: (props, ctx) => [text(ctx.id, str(props.text), 'h3')], Text: (props, ctx) => [text(ctx.id, str(props.text), props.muted === true ? 'caption' : 'body')], Badge: (props, ctx) => [text(ctx.id, str(props.text), 'caption')], Callout: (props, ctx) => { const body = ctx.derive('body'); const message = ctx.derive('text'); return [ { id: ctx.id, component: 'Card', child: body }, ...titled({ ...ctx, id: body }, props.title, [message], [text(message, str(props.text))]), ]; }, Link: (props, ctx) => { const label = ctx.derive('label'); return [ { id: ctx.id, component: 'Button', variant: 'borderless', child: label, action: { functionCall: { call: 'openUrl', args: { url: str(props.url) } } }, }, text(label, str(props.text, str(props.url))), ]; }, Image: (props, ctx) => [ { id: ctx.id, component: 'Image', url: str(props.url), ...(typeof props.alt === 'string' ? { description: props.alt } : {}), }, ], CodeBlock: (props, ctx) => [text(ctx.id, `\`\`\`\n${str(props.code)}\n\`\`\``)], KpiCards: (props, ctx) => cards(ctx, props.items, (item) => [ [str(item.label), 'caption'], [str(item.value), 'h3'], [str(item.delta), 'caption'], ]), SourceCards: (props, ctx) => cards(ctx, props.items, (item) => [ [str(item.title), 'h5'], [str(item.snippet), 'body'], [str(item.source, str(item.url)), 'caption'], ]), DataTable: (props, ctx) => table(ctx, props.title, columnsOf(props.columns), rowsOf(props.rows)), // The basic catalog has no chart: its points, as a table — what a text client gets too. Chart: (props, ctx) => { const xKey = str(props.xKey); const series = (Array.isArray(props.series) ? props.series : []) .filter(isRecord) .filter((each) => typeof each.key === 'string') .map((each) => ({ key: each.key, label: str(each.label, each.key) })); return table(ctx, props.title, [{ key: xKey, label: xKey }, ...series], rowsOf(props.data)); }, }); /** Ids A2UI renderers accept everywhere: the node ids (`root.0.1`) and derived ones (`root.0~title`). */ function safeId(id) { return id.replace(/[^A-Za-z0-9_.~:-]/g, '_'); } /** * A `ui` frame as A2UI components, flat, the root's id `root`. A composed tree (`genui:tree`) maps * node by node — node ids are their positions (`root`, `root.0`, `root.0.2`), stable from the first * preview to the final frame, so a streamed surface only ever grows. A node still held back * (`held`) is left out: its parent references an id that arrives later, which A2UI draws as a * placeholder. */ export function toA2uiComponents(frame, options = {}) { const props = isRecord(frame.props) ? frame.props : {}; const root = frame.component === GENUI_TREE_COMPONENT ? isRecord(props.root) && typeof props.root.type === 'string' ? props.root : undefined : { type: frame.component, props }; if (root === undefined) return []; const out = []; const visit = (node, id, depth) => { if (depth > 32 || node.held === true) return; const children = (Array.isArray(node.children) ? node.children : []).filter((child) => isRecord(child) && typeof child.type === 'string'); const childIds = children.map((_, index) => `${id}.${index}`); const nodeProps = isRecord(node.props) ? node.props : {}; const ctx = { id, children: childIds, derive: (suffix) => `${id}~${suffix}`, incomplete: node.incomplete === true, text: () => options.catalog !== undefined ? componentToText(options.catalog, node.type, nodeProps) : '', }; out.push(...mapNode(node.type, nodeProps, ctx, options)); children.forEach((child, index) => { visit(child, childIds[index], depth + 1); }); }; visit(root, 'root', 1); return out.map((component) => ({ ...component, id: safeId(component.id) })).map(fixRefs); } function fixRefs(component) { const next = { ...component }; if (Array.isArray(next.children)) next.children = next.children.map((id) => safeId(String(id))); for (const key of ['child', 'trigger', 'content']) { if (typeof next[key] === 'string') next[key] = safeId(next[key]); } return next; } function mapNode(type, props, ctx, options) { const own = options.components?.[type]; if (typeof own === 'function') return withRoot(own(props, ctx), ctx, type); if (own === 'custom' || (own === undefined && options.unmapped === 'custom')) { return [ { ...props, id: ctx.id, component: type, ...(ctx.children.length > 0 ? { children: [...ctx.children] } : {}), }, ]; } const builtin = A2UI_BUILTIN_MAPPERS[type]; if (builtin !== undefined) return withRoot(builtin(props, ctx), ctx, type); // Nothing maps it: its words, then whatever it holds. const words = ctx.text(); if (ctx.children.length === 0) return [text(ctx.id, words)]; const label = ctx.derive('text'); return [ { id: ctx.id, component: 'Column', children: words.length > 0 ? [label, ...ctx.children] : [...ctx.children], }, ...(words.length > 0 ? [text(label, words)] : []), ]; } function withRoot(components, ctx, type) { if (!components.some((component) => component.id === ctx.id)) { throw new Error(`a2ui: the mapping of ${type} must return a component with id "${ctx.id}"`); } return components; } /** * {@link toA2uiComponents} that never throws: a mapping that fails (an app mapper that throws or * forgets the root id) draws the component's text instead of breaking the stream it rides on. */ function safeComponents(frame, options) { try { return toA2uiComponents(frame, options); } catch { const props = isRecord(frame.props) ? frame.props : {}; const words = options.catalog !== undefined ? componentToText(options.catalog, frame.component, props) : ''; return words.length > 0 ? [text('root', words)] : []; } } /** The messages that draw (or redraw) a surface: `createSurface` the first time, then its components. */ export function a2uiSurfaceMessages(surfaceId, components, options) { const out = []; if (options.create) { out.push({ version: A2UI_VERSION, createSurface: { surfaceId, catalogId: options.catalogId ?? options.basicCatalogId ?? A2UI_BASIC_CATALOG_ID, ...(options.theme !== undefined ? { theme: options.theme } : {}), ...(options.sendDataModel === true ? { sendDataModel: true } : {}), }, }); } if (components.length > 0) { out.push({ version: A2UI_VERSION, updateComponents: { surfaceId, components } }); } return out; } /** A surface id A2UI clients accept, from a `ui` frame id (`<toolCallId>:ui:0`). */ export function a2uiSurfaceId(frameId) { return frameId.replace(/[^A-Za-z0-9_.~:-]/g, '_'); } /** * A2UI's AG-UI binding (the AG-UI A2UI middleware, CopilotKit's renderer): one `ACTIVITY_SNAPSHOT` * of type `a2ui-surface` per `ui` frame, carrying the whole surface (`createSurface` + * `updateComponents`) with `replace: true` — so each preview of a streaming tree repaints the same * activity in place, keyed by the frame id. */ export function a2uiActivityEvent(frame, options = {}) { const props = isRecord(frame.props) ? frame.props : {}; const surfaceId = a2uiSurfaceId(frame.id); // A withdrawn preview: the surface it painted goes. if (frame.partial === true && Object.keys(props).length === 0) { return { type: 'ACTIVITY_SNAPSHOT', messageId: `a2ui-surface-${surfaceId}`, activityType: A2UI_ACTIVITY_TYPE, content: { [A2UI_OPERATIONS_KEY]: [{ version: A2UI_VERSION, deleteSurface: { surfaceId } }] }, replace: true, }; } const components = safeComponents(frame, options); if (components.length === 0) return null; return { type: 'ACTIVITY_SNAPSHOT', messageId: `a2ui-surface-${surfaceId}`, activityType: A2UI_ACTIVITY_TYPE, content: { [A2UI_OPERATIONS_KEY]: a2uiSurfaceMessages(surfaceId, components, { ...options, create: true, }), }, replace: true, }; } /** * An A2UI catalog definition (v0.9 inline catalog shape) for an agent catalog's components sent * as custom components: `{ catalogId, components: { Name: JSON Schema } }` — what a client * registers, or hands an A2UI-aware runtime (`a2uiClientCapabilities.inlineCatalogs`). */ export function a2uiCatalog(catalog, options) { const components = {}; for (const definition of catalog.modelComponents()) { if (options.only !== undefined && !options.only.includes(definition.name)) continue; const schema = toJsonSchema(definition.props) ?? { type: 'object' }; const properties = isRecord(schema.properties) ? schema.properties : {}; components[definition.name] = { type: 'object', description: definition.description, properties: { component: { const: definition.name }, ...properties, ...(definition.children === true ? { children: { $ref: 'https://a2ui.org/specification/v0_9/common_types.json#/$defs/ChildList', }, } : {}), }, required: ['component', ...(Array.isArray(schema.required) ? schema.required : [])], }; } return { catalogId: options.catalogId, components }; } /** * A client-to-server A2UI message, or an AG-UI `forwardedProps.a2uiAction`, as a {@link UiAction}: * v0.9 `{ version, action: { name, surfaceId, sourceComponentId, timestamp, context } }`, v0.8 * `{ userAction: { … } }`, or the bare action object. A string when it is not one. */ export function readA2uiAction(raw, options = {}) { if (!isRecord(raw)) return 'an A2UI action must be an object'; const inner = isRecord(raw.action) ? raw.action : isRecord(raw.userAction) ? raw.userAction : raw; if (typeof inner.name !== 'string') return 'an A2UI action carries a name'; return readUiAction({ source: 'a2ui', name: inner.name, context: isRecord(inner.context) ? inner.context : {}, ...(typeof inner.surfaceId === 'string' ? { surfaceId: inner.surfaceId } : {}), ...(typeof inner.sourceComponentId === 'string' ? { componentId: inner.sourceComponentId } : {}), ...(typeof inner.timestamp === 'string' ? { timestamp: inner.timestamp } : {}), ...(typeof inner.text === 'string' ? { text: inner.text } : {}), }, { ...options, source: 'a2ui' }); } /** The surface an approval interrupt is shown on: its message, and Approve / Reject buttons. */ export function a2uiApprovalComponents(interrupt) { const button = (id, label, name, variant) => [ { id, component: 'Button', variant, child: `${id}~label`, action: { event: { name, context: { interruptId: interrupt.id } } }, }, text(`${id}~label`, label), ]; // The tool's own prompt, filled on the server: its title is the message, its detail under it. const confirmation = interrupt.metadata?.['agora.confirmation']; const toolName = interrupt.metadata?.['agora.toolName']; const fallback = resolveToolConfirmation(typeof toolName === 'string' ? toolName : '', interrupt.metadata?.['agora.input']); const message = typeof confirmation?.title === 'string' && confirmation.title.length > 0 ? confirmation.title : interrupt.message !== undefined && interrupt.message.length > 0 ? interrupt.message : fallback.title; // The tool's own verb on the button ("Refund"), else the generic one. const verb = typeof confirmation?.verb === 'string' && confirmation.verb.length > 0 ? confirmation.verb : fallback.verb; const detail = typeof confirmation?.detail === 'string' && confirmation.detail.length > 0 ? confirmation.detail : undefined; return [ { id: 'root', component: 'Card', child: 'body' }, { id: 'body', component: 'Column', children: detail !== undefined ? ['message', 'detail', 'buttons'] : ['message', 'buttons'], }, text('message', message, 'body'), ...(detail !== undefined ? [text('detail', detail, 'caption')] : []), { id: 'buttons', component: 'Row', children: ['approve', 'reject'] }, ...button('approve', verb, A2UI_APPROVE_ACTION, 'primary'), ...button('reject', 'Reject', A2UI_REJECT_ACTION, 'default'), ]; } /** * One AG-UI run as an A2UI message stream — the projection the A2UI transport serves. Stateful * (which surfaces exist), pure otherwise: feed it the run's AG-UI events in order. * * - `CUSTOM agora.ui` → a surface per `ui` frame id, created once and updated in place (previews * included); a withdrawn preview deletes it; * - text messages → a text surface each (see {@link A2uiStreamOptions.text}); * - an approval interrupt → a surface with Approve / Reject buttons ({@link A2UI_APPROVE_ACTION}); * any other interrupt → a text surface saying to answer it in the app; * - `RUN_ERROR` → a text surface with the message. */ export class A2uiProjector { options; surfaces = new Set(); texts = new Map(); constructor(options = {}) { this.options = options; } project(event) { switch (event.type) { case 'CUSTOM': return event.name === 'agora.ui' ? this.ui(event.value) : []; case 'TEXT_MESSAGE_START': if (this.options.text === 'omit') return []; this.texts.set(event.messageId, ''); return this.surface(`text-${event.messageId}`, [ { id: 'root', component: 'Text', text: { path: '/text' } }, ]); case 'TEXT_MESSAGE_CONTENT': { if (this.options.text === 'omit') return []; const next = `${this.texts.get(event.messageId) ?? ''}${event.delta}`; this.texts.set(event.messageId, next); return [ { version: A2UI_VERSION, updateDataModel: { surfaceId: `text-${event.messageId}`, path: '/text', value: next }, }, ]; } case 'RUN_FINISHED': { const outcome = event.outcome; if (!isRecord(outcome) || outcome.type !== 'interrupt') return []; const interrupts = Array.isArray(outcome.interrupts) ? outcome.interrupts : []; return interrupts.filter(isRecord).flatMap((interrupt) => { const id = a2uiSurfaceId(`interrupt-${String(interrupt.id)}`); return interrupt.reason === 'tool_approval' ? this.surface(id, a2uiApprovalComponents(interrupt)) : this.surface(id, [ text('root', `${str(interrupt.message, 'The assistant is waiting for an answer.')} (answer it in the app)`), ]); }); } case 'RUN_ERROR': return this.surface(`error-${this.surfaces.size}`, [text('root', event.message)]); default: return []; } } surface(surfaceId, components) { const create = !this.surfaces.has(surfaceId); this.surfaces.add(surfaceId); return a2uiSurfaceMessages(surfaceId, components, { ...this.options, // Text, approvals and errors are basic-catalog components whatever the app's catalog is. catalogId: this.options.basicCatalogId ?? A2UI_BASIC_CATALOG_ID, create, }); } ui(value) { if (!isRecord(value) || typeof value.id !== 'string' || typeof value.component !== 'string') { return []; } const surfaceId = a2uiSurfaceId(value.id); const props = isRecord(value.props) ? value.props : {}; if (value.partial === true && Object.keys(props).length === 0) { if (!this.surfaces.delete(surfaceId)) return []; return [{ version: A2UI_VERSION, deleteSurface: { surfaceId } }]; } const components = safeComponents({ component: value.component, props: props }, this.options); if (components.length === 0) return []; const create = !this.surfaces.has(surfaceId); this.surfaces.add(surfaceId); return a2uiSurfaceMessages(surfaceId, components, { ...this.options, create }); } } function catalogIdsOf(value) { if (!isRecord(value) || !Array.isArray(value.supportedCatalogIds)) return undefined; return value.supportedCatalogIds.filter((id) => typeof id === 'string'); } /** * The catalog ids a client says it draws, from its `a2uiClientCapabilities`: v0.9's * `{ 'v0.9': { supportedCatalogIds } }` (what `MessageProcessor.getRendererCapabilities()` builds), * a `v0.9.1` entry, or a flat `{ supportedCatalogIds }`. `undefined` when it names none. */ export function readA2uiClientCapabilities(raw) { if (!isRecord(raw)) return undefined; const ids = [ ...(catalogIdsOf(raw['v0.9']) ?? []), ...(catalogIdsOf(raw['v0.9.1']) ?? []), ...(catalogIdsOf(raw) ?? []), ]; return ids.length > 0 ? [...new Set(ids)] : undefined; } /** The AG-UI context entry CopilotKit's A2UI provider sends ("A2UI catalog capabilities: …"). */ const AG_UI_CATALOG_CONTEXT = /^A2UI catalog capabilities/i; /** * The catalog ids an AG-UI client advertises: `forwardedProps.a2uiClientCapabilities`, or the * `context` entry CopilotKit's A2UI provider adds (its value lists the ids, one `- <id>` per line). */ export function readAgUiA2uiCatalogIds(input) { const forwarded = isRecord(input.forwardedProps) ? readA2uiClientCapabilities(input.forwardedProps.a2uiClientCapabilities) : undefined; if (forwarded !== undefined) return forwarded; const ids = []; for (const entry of input.context ?? []) { if (!isRecord(entry) || typeof entry.description !== 'string') continue; if (!AG_UI_CATALOG_CONTEXT.test(entry.description) || typeof entry.value !== 'string') continue; for (const line of entry.value.split('\n')) { const id = /^\s*-\s+(\S+:\/\/\S+)/.exec(line)?.[1]; if (id !== undefined) ids.push(id); } } return ids.length > 0 ? [...new Set(ids)] : undefined; } /** * Options for one client: the basic catalog under the id the client advertises (else the * configured `basicCatalogId`, a basic `catalogId`, or the transport's `fallback`), and `catalogId` * resolved — an app catalog stays as configured; the basic catalog takes the negotiated id. */ export function negotiateA2uiCatalog(options, supported, fallback = A2UI_BASIC_CATALOG_ID) { const advertised = (supported ?? []).filter(isA2uiBasicCatalogId); const preferred = [options.basicCatalogId, options.catalogId].find((id) => id !== undefined && advertised.includes(id)); const basic = preferred ?? advertised[0] ?? options.basicCatalogId ?? (isA2uiBasicCatalogId(options.catalogId) ? options.catalogId : undefined) ?? fallback; const catalogId = options.catalogId === undefined || isA2uiBasicCatalogId(options.catalogId) ? basic : options.catalogId; return { ...options, basicCatalogId: basic, catalogId }; } /** * A stored thread as A2UI, for a client that reopens it: each user message as its line, each * assistant step as the surfaces the live stream drew — its text (one `Text`), then every `ui` * frame it kept (the last props per id), mapped exactly as the stream maps them, under the same * surface ids. An approval the thread is still waiting on is not redrawn: ask again by sending. */ export function a2uiThreadReplay(messages, options = {}) { const out = []; const basic = options.basicCatalogId ?? A2UI_BASIC_CATALOG_ID; for (const message of messages) { if (message.role === 'user') { const action = readUiActionText(message.content); out.push({ role: 'user', id: message.id, text: action?.text ?? message.content, ...(action !== null ? { action } : {}), }); continue; } if (message.role !== 'assistant') continue; const surfaces = []; if (options.text !== 'omit' && message.content.trim().length > 0) { surfaces.push(...a2uiSurfaceMessages(`text-${message.id}`, [text('root', message.content)], { ...options, catalogId: basic, create: true, })); } for (const frame of message.ui ?? []) { if (frame.partial === true) continue; const components = safeComponents(frame, options); if (components.length === 0) continue; surfaces.push(...a2uiSurfaceMessages(a2uiSurfaceId(frame.id), components, { ...options, create: true })); } if (surfaces.length > 0) out.push({ role: 'assistant', id: message.id, messages: surfaces }); } return out; } //# sourceMappingURL=core.js.map