@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
192 lines • 8.04 kB
JavaScript
import { builtinJsonSchemaValidator, validateProps, } from './schema.js';
/** Largest action accepted by default, as JSON, in bytes. */
export const UI_ACTION_MAX_BYTES = 8192;
const NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function byteLength(text) {
return new TextEncoder().encode(text).length;
}
/** Bytes of `value` as JSON, or `undefined` when it is not JSON (a cycle, a function, a bigint). */
export function jsonByteLength(value) {
try {
const json = JSON.stringify(value);
return json === undefined ? undefined : byteLength(json);
}
catch {
return undefined;
}
}
/**
* A UI action from untrusted input, checked: a plain object with a short `name` and a JSON
* `context`, no larger than `maxBytes`. Answers the problem as a string when it is refused.
*/
export function readUiAction(raw, options = {}) {
if (!isRecord(raw))
return 'a UI action must be an object';
const size = jsonByteLength(raw);
const max = options.maxBytes ?? UI_ACTION_MAX_BYTES;
if (size === undefined)
return 'a UI action must be JSON';
if (size > max)
return `a UI action may be at most ${max} bytes (this one is ${size})`;
const name = raw.name ?? 'send';
if (typeof name !== 'string' || !NAME.test(name)) {
return 'a UI action name is 1-64 letters, digits, "_", ".", ":" or "-"';
}
const context = raw.context ?? {};
if (!isRecord(context))
return 'a UI action context must be an object';
const source = raw.source === 'sandbox' || raw.source === 'a2ui' || raw.source === 'component'
? raw.source
: (options.source ?? 'component');
const text = typeof raw.text === 'string' ? raw.text.trim().slice(0, 500) : undefined;
const str = (value, limit = 200) => typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : undefined;
const surfaceId = str(raw.surfaceId);
const componentId = str(raw.componentId);
const title = str(raw.title);
const timestamp = str(raw.timestamp, 40);
return {
source,
name,
context,
...(text !== undefined && text.length > 0 ? { text } : {}),
...(surfaceId !== undefined ? { surfaceId } : {}),
...(componentId !== undefined ? { componentId } : {}),
...(title !== undefined ? { title } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
};
}
/**
* The action a sandbox's `agent.send(payload)` stands for: `payload.text` is what the user says,
* `payload.action` (optional) names it, and every other field is a value.
*/
export function sandboxAction(payload, frame = {}) {
const values = isRecord(payload) ? { ...payload } : { value: payload };
const text = typeof values.text === 'string' ? values.text.trim().slice(0, 500) : undefined;
const name = typeof values.action === 'string' && NAME.test(values.action) ? values.action : 'send';
delete values.text;
delete values.action;
return {
source: 'sandbox',
name,
context: values,
...(text !== undefined && text.length > 0 ? { text } : {}),
...(frame.surfaceId !== undefined ? { surfaceId: frame.surfaceId } : {}),
...(frame.componentId !== undefined ? { componentId: frame.componentId } : {}),
...(frame.title !== undefined ? { title: frame.title } : {}),
timestamp: new Date().toISOString(),
};
}
/**
* Check an action's `context` against a schema (Zod, Valibot, ArkType — any Standard Schema — or a
* JSON Schema). Answers the parsed context, or the issues.
*/
export async function validateUiActionContext(action, schema) {
const result = await validateProps(schema, action.context, builtinJsonSchemaValidator);
if (!result.ok)
return { ok: false, issues: result.issues };
return isRecord(result.value)
? { ok: true, context: result.value }
: { ok: false, issues: [{ path: [], message: 'must be an object' }] };
}
/**
* The user message an action becomes: what the user said (or a sentence naming the action), then
* where it came from and its values as a JSON block — the model reads both.
*/
export function uiActionText(action) {
const said = action.text ?? `I used "${action.name}"${action.title ? ` in ${action.title}` : ''}.`;
const origin = [
action.source === 'sandbox' ? 'interactive view' : action.source === 'a2ui' ? 'A2UI' : 'UI',
action.title !== undefined ? `"${action.title}"` : undefined,
action.surfaceId !== undefined ? `surface ${action.surfaceId}` : undefined,
action.componentId !== undefined ? `component ${action.componentId}` : undefined,
]
.filter((part) => part !== undefined)
.join(', ');
const values = Object.keys(action.context).length > 0;
return [
said,
'',
`[UI action "${action.name}" from ${origin}]${values ? '' : ' (no values)'}`,
...(values ? ['```json', JSON.stringify(action.context, null, 2), '```'] : []),
].join('\n');
}
const ACTION_LINE = /^\[UI action "([A-Za-z0-9_.:-]{1,64})" from ([^\n]*?)\]( \(no values\))?$/;
/**
* The inverse of {@link uiActionText}: a user message that is a UI action, as its parts — or `null`
* for any other message. The message itself stays what it was (the model reads it whole); this is
* for drawing it: the user's sentence as a chip, the values out of sight.
*/
export function readUiActionText(message) {
const marker = message.lastIndexOf('\n\n[UI action "');
if (marker < 0)
return null;
const said = message.slice(0, marker).trim();
const rest = message.slice(marker + 2).replace(/\s+$/, '');
const newline = rest.indexOf('\n');
const header = newline < 0 ? rest : rest.slice(0, newline);
const match = ACTION_LINE.exec(header);
if (match === null || said.length === 0)
return null;
const [, name, origin, noValues] = match;
let context = {};
const body = newline < 0 ? '' : rest.slice(newline + 1);
if (noValues !== undefined) {
if (body.length > 0)
return null;
}
else {
const block = /^```json\n([\s\S]*)\n```$/.exec(body);
if (block === null)
return null;
try {
const parsed = JSON.parse(block[1]);
if (!isRecord(parsed))
return null;
context = parsed;
}
catch {
return null;
}
}
const source = origin.startsWith('interactive view')
? 'sandbox'
: origin.startsWith('A2UI')
? 'a2ui'
: 'component';
const title = /^[^"]*"([^"]*)"/.exec(origin)?.[1];
return {
text: said,
name,
source,
...(title !== undefined && title.length > 0 ? { title } : {}),
context,
};
}
function shortValue(value) {
if (typeof value === 'string')
return value.length > 24 ? `${value.slice(0, 23)}…` : value;
if (typeof value === 'number' || typeof value === 'boolean')
return String(value);
return undefined;
}
/**
* One line for a UI action — what a chip says: the user's sentence, then up to `maxValues` of its
* plain values (`"Recalculate · people: 4, tip: 15"`). Nested values are left out (the model has
* them; a chip has no room).
*/
export function uiActionSummary(action, options = {}) {
const label = action.text ?? ('name' in action ? action.name : '');
const max = options.maxValues ?? 3;
const values = Object.entries(action.context).flatMap(([key, value]) => {
const short = shortValue(value);
return short === undefined ? [] : [`${key}: ${short}`];
});
if (values.length === 0 || max <= 0)
return label;
const shown = values.slice(0, max).join(', ');
return `${label} · ${shown}${values.length > max ? `, +${values.length - max}` : ''}`;
}
//# sourceMappingURL=actions.js.map